@pisell/pisellos 2.2.291 → 2.2.293

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 (174) hide show
  1. package/dist/core/index.d.ts +7 -0
  2. package/dist/core/index.js +108 -65
  3. package/dist/model/strategy/adapter/itemRule/adapter.d.ts +4 -1
  4. package/dist/model/strategy/adapter/itemRule/adapter.js +135 -35
  5. package/dist/model/strategy/adapter/itemRule/evaluator.d.ts +8 -1
  6. package/dist/model/strategy/adapter/itemRule/evaluator.js +25 -1
  7. package/dist/model/strategy/adapter/itemRule/type.d.ts +44 -0
  8. package/dist/model/strategy/adapter/itemRule/type.js +19 -1
  9. package/dist/model/strategy/adapter/promotion/index.js +0 -9
  10. package/dist/modules/BookingContext/index.d.ts +3 -0
  11. package/dist/modules/BookingContext/index.js +42 -13
  12. package/dist/modules/BookingContext/types.d.ts +4 -4
  13. package/dist/modules/BookingContext/types.js +3 -3
  14. package/dist/modules/OpenData/types.d.ts +1 -0
  15. package/dist/modules/Order/index.d.ts +18 -3
  16. package/dist/modules/Order/index.js +885 -485
  17. package/dist/modules/Order/salesNotes.d.ts +31 -0
  18. package/dist/modules/Order/salesNotes.js +492 -0
  19. package/dist/modules/Order/types.d.ts +93 -5
  20. package/dist/modules/Order/types.js +5 -0
  21. package/dist/modules/Order/utils.d.ts +12 -0
  22. package/dist/modules/Order/utils.js +96 -31
  23. package/dist/modules/Product/types.d.ts +2 -0
  24. package/dist/modules/ProductList/clientDataVariants.d.ts +50 -0
  25. package/dist/modules/ProductList/clientDataVariants.js +216 -0
  26. package/dist/modules/ProductList/index.d.ts +18 -3
  27. package/dist/modules/ProductList/index.js +220 -38
  28. package/dist/modules/ProductList/types.d.ts +10 -0
  29. package/dist/modules/Quotation/index.d.ts +22 -0
  30. package/dist/modules/Quotation/index.js +293 -28
  31. package/dist/modules/ResourcePlanner/index.d.ts +0 -22
  32. package/dist/modules/ResourcePlanner/index.js +1 -180
  33. package/dist/modules/ResourcePlanner/localClock.d.ts +33 -0
  34. package/dist/modules/ResourcePlanner/localClock.js +183 -0
  35. package/dist/modules/ResourcePlanner/planner.d.ts +5 -14
  36. package/dist/modules/ResourcePlanner/planner.js +2111 -958
  37. package/dist/modules/ResourcePlanner/types.d.ts +359 -187
  38. package/dist/modules/ResourcePlanner/types.js +33 -1
  39. package/dist/plugins/request.d.ts +1 -0
  40. package/dist/server/index.d.ts +17 -2
  41. package/dist/server/index.js +1134 -932
  42. package/dist/server/modules/floor-plan/index.d.ts +4 -0
  43. package/dist/server/modules/floor-plan/index.js +240 -98
  44. package/dist/server/modules/menu/index.js +48 -23
  45. package/dist/server/modules/order/index.d.ts +17 -7
  46. package/dist/server/modules/order/index.js +419 -207
  47. package/dist/server/modules/products/index.d.ts +10 -4
  48. package/dist/server/modules/products/index.js +177 -84
  49. package/dist/server/modules/resource/index.js +21 -13
  50. package/dist/server/utils/product.d.ts +1 -1
  51. package/dist/server/utils/product.js +186 -22
  52. package/dist/server/utils/small-ticket.d.ts +9 -1
  53. package/dist/server/utils/small-ticket.js +131 -84
  54. package/dist/solution/BaseSales/index.d.ts +26 -3
  55. package/dist/solution/BaseSales/index.js +1384 -905
  56. package/dist/solution/BaseSales/types.d.ts +2 -0
  57. package/dist/solution/BaseSales/utils/transformBaseProductToOrderProduct.d.ts +3 -0
  58. package/dist/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +10 -3
  59. package/dist/solution/BaseSales/utils.js +6 -3
  60. package/dist/solution/BookingByStep/index.d.ts +4 -4
  61. package/dist/solution/BookingTicket/index.d.ts +1 -1
  62. package/dist/solution/BookingTicket/index.js +4 -2
  63. package/dist/solution/BookingTicket/utils/addProductDecision.d.ts +2 -1
  64. package/dist/solution/BookingTicket/utils/addProductDecision.js +6 -3
  65. package/dist/solution/BookingTicket/utils/orderCustomer.js +3 -2
  66. package/dist/solution/RegisterAndLogin/config.d.ts +9 -3
  67. package/dist/solution/RegisterAndLogin/config.js +95 -40
  68. package/dist/solution/RegisterAndLogin/index.js +4 -1
  69. package/dist/solution/ScanOrder/index.d.ts +1 -0
  70. package/dist/solution/ScanOrder/index.js +31 -27
  71. package/dist/solution/ScanOrder/types.d.ts +2 -0
  72. package/dist/solution/ScanOrder/utils.d.ts +1 -0
  73. package/dist/solution/ScanOrder/utils.js +8 -3
  74. package/dist/solution/UnifiedBookingSales/index.d.ts +128 -74
  75. package/dist/solution/UnifiedBookingSales/index.js +3877 -963
  76. package/dist/solution/UnifiedBookingSales/remoteOccupancyCache.d.ts +49 -0
  77. package/dist/solution/UnifiedBookingSales/remoteOccupancyCache.js +430 -0
  78. package/dist/solution/UnifiedBookingSales/types.d.ts +161 -77
  79. package/dist/solution/UnifiedBookingSales/types.js +4 -9
  80. package/dist/solution/VenueBooking/index.d.ts +3 -0
  81. package/dist/solution/VenueBooking/index.js +549 -497
  82. package/dist/solution/VenueBooking/utils/dateSummary.d.ts +1 -0
  83. package/dist/solution/VenueBooking/utils/dateSummary.js +3 -1
  84. package/dist/solution/VenueBooking/utils/slotMerge.d.ts +2 -0
  85. package/dist/solution/VenueBooking/utils/slotMerge.js +3 -1
  86. package/dist/utils/platform.d.ts +8 -0
  87. package/dist/utils/platform.js +15 -0
  88. package/lib/core/index.d.ts +7 -0
  89. package/lib/core/index.js +20 -2
  90. package/lib/model/strategy/adapter/itemRule/adapter.d.ts +4 -1
  91. package/lib/model/strategy/adapter/itemRule/adapter.js +73 -4
  92. package/lib/model/strategy/adapter/itemRule/evaluator.d.ts +8 -1
  93. package/lib/model/strategy/adapter/itemRule/evaluator.js +23 -1
  94. package/lib/model/strategy/adapter/itemRule/type.d.ts +44 -0
  95. package/lib/model/strategy/adapter/itemRule/type.js +19 -1
  96. package/lib/modules/BookingContext/index.d.ts +3 -0
  97. package/lib/modules/BookingContext/index.js +35 -8
  98. package/lib/modules/BookingContext/types.d.ts +4 -4
  99. package/lib/modules/BookingContext/types.js +3 -3
  100. package/lib/modules/OpenData/types.d.ts +1 -0
  101. package/lib/modules/Order/index.d.ts +18 -3
  102. package/lib/modules/Order/index.js +325 -28
  103. package/lib/modules/Order/salesNotes.d.ts +31 -0
  104. package/lib/modules/Order/salesNotes.js +382 -0
  105. package/lib/modules/Order/types.d.ts +93 -5
  106. package/lib/modules/Order/types.js +4 -0
  107. package/lib/modules/Order/utils.d.ts +12 -0
  108. package/lib/modules/Order/utils.js +85 -16
  109. package/lib/modules/Product/types.d.ts +2 -0
  110. package/lib/modules/ProductList/clientDataVariants.d.ts +50 -0
  111. package/lib/modules/ProductList/clientDataVariants.js +204 -0
  112. package/lib/modules/ProductList/index.d.ts +18 -3
  113. package/lib/modules/ProductList/index.js +137 -8
  114. package/lib/modules/ProductList/types.d.ts +10 -0
  115. package/lib/modules/Quotation/index.d.ts +22 -0
  116. package/lib/modules/Quotation/index.js +158 -11
  117. package/lib/modules/ResourcePlanner/index.d.ts +0 -22
  118. package/lib/modules/ResourcePlanner/index.js +9 -129
  119. package/lib/modules/ResourcePlanner/localClock.d.ts +33 -0
  120. package/lib/modules/ResourcePlanner/localClock.js +205 -0
  121. package/lib/modules/ResourcePlanner/planner.d.ts +5 -14
  122. package/lib/modules/ResourcePlanner/planner.js +1807 -783
  123. package/lib/modules/ResourcePlanner/types.d.ts +359 -187
  124. package/lib/modules/ResourcePlanner/types.js +13 -1
  125. package/lib/plugins/request.d.ts +1 -0
  126. package/lib/server/index.d.ts +17 -2
  127. package/lib/server/index.js +149 -21
  128. package/lib/server/modules/floor-plan/index.d.ts +4 -0
  129. package/lib/server/modules/floor-plan/index.js +60 -7
  130. package/lib/server/modules/menu/index.js +26 -4
  131. package/lib/server/modules/order/index.d.ts +17 -7
  132. package/lib/server/modules/order/index.js +194 -57
  133. package/lib/server/modules/products/index.d.ts +10 -4
  134. package/lib/server/modules/products/index.js +93 -23
  135. package/lib/server/modules/resource/index.js +12 -2
  136. package/lib/server/utils/product.d.ts +1 -1
  137. package/lib/server/utils/product.js +163 -3
  138. package/lib/server/utils/small-ticket.d.ts +9 -1
  139. package/lib/server/utils/small-ticket.js +123 -79
  140. package/lib/solution/BaseSales/index.d.ts +26 -3
  141. package/lib/solution/BaseSales/index.js +396 -70
  142. package/lib/solution/BaseSales/types.d.ts +2 -0
  143. package/lib/solution/BaseSales/utils/transformBaseProductToOrderProduct.d.ts +3 -0
  144. package/lib/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +9 -2
  145. package/lib/solution/BaseSales/utils.js +3 -0
  146. package/lib/solution/BookingByStep/index.d.ts +4 -4
  147. package/lib/solution/BookingTicket/index.d.ts +1 -1
  148. package/lib/solution/BookingTicket/index.js +2 -1
  149. package/lib/solution/BookingTicket/utils/addProductDecision.d.ts +2 -1
  150. package/lib/solution/BookingTicket/utils/addProductDecision.js +5 -2
  151. package/lib/solution/BookingTicket/utils/orderCustomer.js +2 -1
  152. package/lib/solution/RegisterAndLogin/config.d.ts +9 -3
  153. package/lib/solution/RegisterAndLogin/config.js +49 -8
  154. package/lib/solution/RegisterAndLogin/index.js +4 -1
  155. package/lib/solution/ScanOrder/index.d.ts +1 -0
  156. package/lib/solution/ScanOrder/index.js +5 -1
  157. package/lib/solution/ScanOrder/types.d.ts +2 -0
  158. package/lib/solution/ScanOrder/utils.d.ts +1 -0
  159. package/lib/solution/ScanOrder/utils.js +4 -0
  160. package/lib/solution/UnifiedBookingSales/index.d.ts +128 -74
  161. package/lib/solution/UnifiedBookingSales/index.js +2592 -424
  162. package/lib/solution/UnifiedBookingSales/remoteOccupancyCache.d.ts +49 -0
  163. package/lib/solution/UnifiedBookingSales/remoteOccupancyCache.js +316 -0
  164. package/lib/solution/UnifiedBookingSales/types.d.ts +161 -77
  165. package/lib/solution/UnifiedBookingSales/types.js +4 -6
  166. package/lib/solution/VenueBooking/index.d.ts +3 -0
  167. package/lib/solution/VenueBooking/index.js +33 -9
  168. package/lib/solution/VenueBooking/utils/dateSummary.d.ts +1 -0
  169. package/lib/solution/VenueBooking/utils/dateSummary.js +3 -1
  170. package/lib/solution/VenueBooking/utils/slotMerge.d.ts +2 -0
  171. package/lib/solution/VenueBooking/utils/slotMerge.js +3 -1
  172. package/lib/utils/platform.d.ts +8 -0
  173. package/lib/utils/platform.js +22 -0
  174. package/package.json +1 -1
@@ -5,26 +5,36 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  var _exportNames = {
7
7
  buildPlannerDiscountResult: true,
8
- normalizeProductForPlanner: true,
9
- buildPlannerResourcesFromV2: true,
10
- collectPlannerResourceIds: true,
11
- buildBookingFromAssignment: true,
12
- buildBookingFromAssignments: true,
13
- buildPlannerLineIdentity: true,
8
+ normalizeProductForAvailability: true,
9
+ buildAvailabilityResourcesFromV2: true,
10
+ collectAvailabilityResourceIds: true,
11
+ buildAvailabilityLineIdentity: true,
12
+ buildBookingFromAvailabilityAssignment: true,
13
+ buildBookingFromAvailabilityAssignments: true,
14
14
  UnifiedBookingSalesImpl: true,
15
- UnifiedBookingSales: true
15
+ UnifiedBookingSales: true,
16
+ AvailabilityError: true
16
17
  };
18
+ Object.defineProperty(exports, "AvailabilityError", {
19
+ enumerable: true,
20
+ get: function () {
21
+ return _ResourcePlanner.AvailabilityError;
22
+ }
23
+ });
17
24
  exports.UnifiedBookingSalesImpl = exports.UnifiedBookingSales = void 0;
18
- exports.buildBookingFromAssignment = buildBookingFromAssignment;
19
- exports.buildBookingFromAssignments = buildBookingFromAssignments;
25
+ exports.buildAvailabilityLineIdentity = buildAvailabilityLineIdentity;
26
+ exports.buildAvailabilityResourcesFromV2 = buildAvailabilityResourcesFromV2;
27
+ exports.buildBookingFromAvailabilityAssignment = buildBookingFromAvailabilityAssignment;
28
+ exports.buildBookingFromAvailabilityAssignments = buildBookingFromAvailabilityAssignments;
20
29
  exports.buildPlannerDiscountResult = buildPlannerDiscountResult;
21
- exports.buildPlannerLineIdentity = buildPlannerLineIdentity;
22
- exports.buildPlannerResourcesFromV2 = buildPlannerResourcesFromV2;
23
- exports.collectPlannerResourceIds = collectPlannerResourceIds;
24
- exports.normalizeProductForPlanner = normalizeProductForPlanner;
30
+ exports.collectAvailabilityResourceIds = collectAvailabilityResourceIds;
31
+ exports.normalizeProductForAvailability = normalizeProductForAvailability;
25
32
  var _dayjs = _interopRequireDefault(require("dayjs"));
33
+ var _lodashEs = require("lodash-es");
26
34
  var _modules = require("../../modules");
35
+ var _utils = require("../../modules/BookingContext/utils");
27
36
  var _getDateIsInSchedule = require("../../modules/Schedule/getDateIsInSchedule");
37
+ var _ResourcePlanner = require("../../modules/ResourcePlanner");
28
38
  var _types = require("./types");
29
39
  Object.keys(_types).forEach(function (key) {
30
40
  if (key === "default" || key === "__esModule") return;
@@ -37,12 +47,59 @@ Object.keys(_types).forEach(function (key) {
37
47
  }
38
48
  });
39
49
  });
50
+ var _remoteOccupancyCache = require("./remoteOccupancyCache");
40
51
  var _BookingTicket = require("../BookingTicket");
52
+ var _localClock = require("../../modules/ResourcePlanner/localClock");
53
+ var _platform = require("../../utils/platform");
54
+ var _itemRule = require("../../model/strategy/adapter/itemRule");
41
55
  function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
42
- // import { BaseSalesImpl } from '../BaseSales';
43
-
44
- const OPEN_DATA_SECTION_CODES = ['sale', 'reservation', 'fulfillment', 'menu', 'workflow'];
56
+ const OPEN_DATA_SECTION_CODES = ['sale', 'reservation', 'fulfillment', 'menu', 'workflow', 'basic', 'checkout', 'availability'];
45
57
  const OPEN_DATA_CACHE_TTL = 5 * 60 * 1000;
58
+ function normalizePositiveInteger(value) {
59
+ const normalized = Number(value);
60
+ if (!Number.isInteger(normalized) || normalized <= 0) return undefined;
61
+ return normalized;
62
+ }
63
+ function getCandidateDurationMinutes(candidate) {
64
+ const startAt = candidate.bookingStartAt || candidate.startAt;
65
+ const endAt = candidate.bookingEndAt || candidate.endAt;
66
+ try {
67
+ const start = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(startAt));
68
+ const end = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(endAt));
69
+ const minutes = Math.round((end - start) / 60000);
70
+ return Number.isInteger(minutes) && minutes > 0 ? minutes : undefined;
71
+ } catch (_error) {
72
+ return undefined;
73
+ }
74
+ }
75
+ function getCandidateStartTime(candidate) {
76
+ if (/^([01]\d|2[0-3]):[0-5]\d$/.test(candidate.startTime || '')) {
77
+ return candidate.startTime;
78
+ }
79
+ const startAt = candidate.bookingStartAt || candidate.startAt;
80
+ try {
81
+ const start = (0, _localClock.parseLocalDateTime)(startAt);
82
+ return `${String(start.hour).padStart(2, '0')}:${String(start.minute).padStart(2, '0')}`;
83
+ } catch (_error) {
84
+ return undefined;
85
+ }
86
+ }
87
+ function normalizePositiveIntegerList(value) {
88
+ if (!Array.isArray(value)) return [];
89
+ return Array.from(new Set(value.map(item => normalizePositiveInteger(item)).filter(item => item !== undefined)));
90
+ }
91
+ function toConfiguredBoolean(value) {
92
+ if (typeof value === 'boolean') return value;
93
+ if (typeof value === 'number') return value !== 0;
94
+ if (typeof value === 'string') {
95
+ return ['1', 'true', 'yes', 'y', 'on'].includes(value.trim().toLowerCase());
96
+ }
97
+ return false;
98
+ }
99
+ function normalizeItemRuleStrategyConfigs(value) {
100
+ if (!Array.isArray(value)) return [];
101
+ return value.filter(config => Boolean(config && typeof config === 'object' && config.metadata?.type === 'item_rule'));
102
+ }
46
103
  function isRecord(value) {
47
104
  return !!value && typeof value === 'object' && !Array.isArray(value);
48
105
  }
@@ -149,82 +206,477 @@ function filterCustomerBoundDiscounts(discountList) {
149
206
  if (!Array.isArray(discountList)) return [];
150
207
  return discountList.filter(discount => !isCustomerBoundDiscount(discount));
151
208
  }
152
-
153
- /** Maps several legacy product shapes into the Planner's small, stable vocabulary. */
209
+ const DEFAULT_AVAILABILITY_SLOT_STEP_MINUTES = 30;
210
+ const DEFAULT_AVAILABILITY_BUSINESS_HOURS = {
211
+ // Same start/end is interpreted as an overnight range, i.e. 24 hours.
212
+ startTime: '00:00',
213
+ endTime: '00:00'
214
+ };
215
+ const MAX_AVAILABILITY_RANGE_DAYS = 90;
216
+ const MAX_AVAILABILITY_CONTEXT_REGISTRY = 128;
217
+ const MAX_AVAILABILITY_PROJECTION_POOL = 16;
218
+ const MAX_REMOTE_OCCUPANCY_RESOURCE_DATE_CELLS = 4096;
219
+ function isValidClockTime(value) {
220
+ const match = /^(\d{2}):(\d{2})$/.exec(value);
221
+ if (!match) return false;
222
+ return Number(match[1]) < 24 && Number(match[2]) < 60;
223
+ }
224
+ function assertValidAvailabilityTimezone(timezoneName) {
225
+ if (!String(timezoneName || '').trim()) {
226
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'timezone label 不能为空', {
227
+ timezone: timezoneName
228
+ });
229
+ }
230
+ }
231
+ function normalizeAvailabilityLocalDateTime(value, fixedOffsetMinutes) {
232
+ if (value == null || value === '') return null;
233
+ try {
234
+ const localText = typeof value?.format === 'function' ? value.format('YYYY-MM-DDTHH:mm:ss') : String(value);
235
+ return (0, _localClock.formatLocalDateTime)((0, _localClock.parseLocalDateTime)(localText), fixedOffsetMinutes);
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+ function normalizeAvailabilityLocalDateTimeRange(params) {
241
+ const startText = normalizeAvailabilityLocalDateTime(params.startAt, params.fixedOffsetMinutes);
242
+ const endText = normalizeAvailabilityLocalDateTime(params.endAt, params.fixedOffsetMinutes);
243
+ if (!startText || !endText) return null;
244
+ const startScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(startText));
245
+ let endScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(endText));
246
+ if (endScalar <= startScalar && params.rollOverEnd) {
247
+ endScalar = (0, _localClock.addLocalCalendarDays)(endScalar, 1);
248
+ }
249
+ if (endScalar <= startScalar) return null;
250
+ return {
251
+ startAt: startText,
252
+ endAt: (0, _localClock.formatLocalDateTime)(endScalar, params.fixedOffsetMinutes),
253
+ startScalar,
254
+ endScalar
255
+ };
256
+ }
257
+ function sameAvailabilityId(left, right) {
258
+ return left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right);
259
+ }
260
+ function uniqueAvailabilityIds(values) {
261
+ const result = new Map();
262
+ values.forEach(value => {
263
+ const id = toNumberId(value);
264
+ if (id !== undefined) result.set(String(id), id);
265
+ });
266
+ return Array.from(result.values());
267
+ }
268
+ function assertAvailabilityCatalogProductIds(productIds, context) {
269
+ if (productIds == null) return;
270
+ if (!Array.isArray(productIds)) {
271
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', `${context} 必须是 number[]`, {
272
+ context,
273
+ productIds
274
+ });
275
+ }
276
+ const invalidIds = productIds.filter(id => typeof id !== 'number' || !Number.isFinite(id));
277
+ if (invalidIds.length > 0) {
278
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', `${context} 只能包含有限数值商品 ID`, {
279
+ context,
280
+ invalidIds,
281
+ productIds
282
+ });
283
+ }
284
+ }
285
+ function normalizeResourceType(_value) {
286
+ // 当前业务只支持每个资源组选择一个具体资源。
287
+ // 保留公共联合类型以兼容已有调用方,但暂不启用 multiple/capacity 分支。
288
+ return 'single';
289
+ }
154
290
  function resolveProductKind(product) {
155
- if (product.duration) return 'duration';
291
+ if (product.duration || product.service_duration) return 'duration';
292
+ if (product.extension_type === 'venue_slot') return 'venue_slot';
156
293
  if (['session_product', 'session_ticket'].includes(product.extension_type)) return 'session';
157
- if (product.cartDetailValue?.session || product._extend?.other?.session) return 'session';
158
- if (Array.isArray(product['schedule.ids']) && product['schedule.ids'].length > 0) return 'session';
294
+ if (product.cartDetailValue?.session || product._extend?.other?.session || product.session) {
295
+ return 'session';
296
+ }
297
+ const scheduleIds = product['schedule.ids'] || product.schedule_ids;
298
+ if (Array.isArray(scheduleIds) && scheduleIds.length > 0) return 'session';
159
299
  return 'normal';
160
300
  }
161
- function normalizeSchedule(product) {
162
- const session = product.cartDetailValue?.session || product._extend?.other?.session || product.session;
163
- if (session?.start_at && session?.end_at) {
164
- return [{
165
- start_at: session.start_at,
166
- end_at: session.end_at
167
- }];
168
- }
169
- const schedules = product.schedules || product.schedule || product._schedule;
170
- if (Array.isArray(schedules)) {
171
- return schedules.map(item => ({
172
- start_at: item.start_at || item.start || item.start_time,
173
- end_at: item.end_at || item.end || item.end_time
174
- })).filter(item => item.start_at && item.end_at);
175
- }
176
- return [];
301
+ function expandScheduleDateRanges(values) {
302
+ if (!Array.isArray(values)) return [];
303
+ const dates = new Set();
304
+ values.forEach(value => {
305
+ if (typeof value === 'string') {
306
+ try {
307
+ dates.add((0, _localClock.formatLocalDate)((0, _localClock.parseLocalDate)(value.slice(0, 10))));
308
+ } catch {
309
+ // Ignore invalid configuration entries.
310
+ }
311
+ return;
312
+ }
313
+ const startValue = value?.start || value?.start_date || value?.date;
314
+ const endValue = value?.end || value?.end_date || startValue;
315
+ let start;
316
+ let end;
317
+ try {
318
+ start = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(String(startValue).slice(0, 10)));
319
+ end = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(String(endValue).slice(0, 10)));
320
+ } catch {
321
+ return;
322
+ }
323
+ if (end < start) return;
324
+ let cursor = start;
325
+ let guard = 0;
326
+ while (cursor <= end && guard < MAX_AVAILABILITY_RANGE_DAYS) {
327
+ dates.add((0, _localClock.formatLocalDate)(cursor));
328
+ cursor = (0, _localClock.addLocalCalendarDays)(cursor, 1);
329
+ guard += 1;
330
+ }
331
+ });
332
+ return Array.from(dates).sort();
177
333
  }
178
-
179
- /**
180
- * Preserves form ownership while flattening default/optional resource ids.
181
- * `formId` must survive because the order API requires it under booking.resources[].
182
- */
183
- function normalizeRequirements(product) {
334
+ function normalizeRequirementGroups(product) {
184
335
  const resources = product.product_resource?.resources;
185
336
  if (!Array.isArray(resources)) return [];
186
- return resources.filter(resource => resource?.status !== 0).map(resource => ({
187
- formId: toNumberId(resource.id),
188
- resourceIds: [...(Array.isArray(resource.default_resource) ? resource.default_resource : []), ...(Array.isArray(resource.optional_resource) ? resource.optional_resource : [])].map(id => toNumberId(id)).filter(id => id !== undefined),
189
- resourceType: resource.type || resource.select_type?.type || 'single',
190
- required: resource.status === 1,
191
- capacityRequired: Number(product.capacity?.default || product.capacity || 1) || 1
192
- }));
337
+ return resources.filter(resource => resource?.status !== 0).map((resource, index) => {
338
+ const formId = toNumberId(resource.id ?? resource.form_id);
339
+ const required = resource.required ?? resource.status === 1;
340
+ const resourceType = normalizeResourceType(resource.type);
341
+ const combinedIds = resource.combined_resource?.status === 1 ? resource.combined_resource?.resource_ids : [];
342
+ const resourceIds = uniqueAvailabilityIds([...(Array.isArray(resource.default_resource) ? resource.default_resource : []), ...(Array.isArray(resource.optional_resource) ? resource.optional_resource : []), ...(Array.isArray(combinedIds) ? combinedIds : [])]);
343
+ const min = required ? 1 : 0;
344
+ const max = 1;
345
+ return {
346
+ id: String(formId ?? resource.code ?? `requirement-${index}`),
347
+ formId,
348
+ title: pickLocalizedText(resource.title || resource.name || resource.main_field),
349
+ code: resource.code,
350
+ resourceType,
351
+ required: Boolean(required),
352
+ min,
353
+ max,
354
+ resourceIds,
355
+ capacityRequired: Math.max(1, Number(resource.capacity_required ?? product.capacity?.default ?? product.capacity ?? 1) || 1),
356
+ raw: resource
357
+ };
358
+ });
359
+ }
360
+ function normalizeScheduleRepeatType(value) {
361
+ if (value === 'daily' || value === 1 || value === '1') return 'daily';
362
+ if (value === 'weekly' || value === 2 || value === '2') return 'weekly';
363
+ return 'none';
364
+ }
365
+ function normalizeAbsoluteSessionRange(params) {
366
+ const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
367
+ const range = normalizeAvailabilityLocalDateTimeRange({
368
+ startAt: params.startAt,
369
+ endAt: params.endAt,
370
+ fixedOffsetMinutes,
371
+ rollOverEnd: true
372
+ });
373
+ if (!range) return null;
374
+ return {
375
+ startAt: range.startAt,
376
+ endAt: range.endAt,
377
+ scheduleRefs: [{
378
+ scheduleId: params.scheduleId,
379
+ timeSlotId: params.timeSlotId,
380
+ source: params.source
381
+ }]
382
+ };
383
+ }
384
+ function normalizeScheduleItem(schedule, fixedOffsetMinutes) {
385
+ const rules = [];
386
+ const ranges = [];
387
+ const scheduleId = toNumberId(schedule.id ?? schedule.schedule_id);
388
+ const repeatType = normalizeScheduleRepeatType(schedule.repeat_type);
389
+ const repeatRule = schedule.repeat_rule || {};
390
+ const startAt = schedule.start_at || schedule.start || schedule.start_time;
391
+ const endAt = schedule.end_at || schedule.end || schedule.end_time;
392
+ const startDate = startAt ? String(startAt).slice(0, 10) : undefined;
393
+ const configuredEndDate = repeatRule.end?.type === 'date' ? repeatRule.end?.end_date : undefined;
394
+ const endDate = configuredEndDate ? String(configuredEndDate).slice(0, 10) : undefined;
395
+ const includedDates = expandScheduleDateRanges(repeatRule.included_date);
396
+ const excludedDates = expandScheduleDateRanges(repeatRule.excluded_date);
397
+ const addRule = params => {
398
+ const mode = repeatType === 'weekly' ? 'weekly' : 'daily';
399
+ rules.push({
400
+ id: `${String(scheduleId ?? 'schedule')}:${params.idSuffix}`,
401
+ mode,
402
+ startTime: params.startTime.slice(0, 5),
403
+ endTime: params.endTime.slice(0, 5),
404
+ startDate,
405
+ endDate,
406
+ frequency: Math.max(1, Number(repeatRule.frequency || 1)),
407
+ weekdays: repeatType === 'weekly' ? (repeatRule.frequency_date || []).map(Number).filter(Number.isFinite) : undefined,
408
+ includeDates: includedDates,
409
+ excludeDates: excludedDates,
410
+ scheduleRef: {
411
+ scheduleId,
412
+ timeSlotId: params.timeSlotId,
413
+ source: `schedule_${schedule.type || 'standard'}`
414
+ }
415
+ });
416
+ };
417
+ if (schedule.type === 'designation') {
418
+ const designations = Array.isArray(schedule.designation) ? schedule.designation : schedule.designation ? [schedule.designation] : [];
419
+ designations.forEach(designation => {
420
+ const range = normalizeAbsoluteSessionRange({
421
+ startAt: `${designation.start_date} ${designation.start_time}`,
422
+ endAt: `${designation.end_date || designation.start_date} ${designation.end_time}`,
423
+ scheduleId,
424
+ source: 'schedule_designation',
425
+ fixedOffsetMinutes
426
+ });
427
+ if (range) ranges.push(range);
428
+ });
429
+ return {
430
+ rules,
431
+ ranges
432
+ };
433
+ }
434
+ if (schedule.type === 'time-slots' && Array.isArray(schedule.time_slot)) {
435
+ schedule.time_slot.forEach((slot, index) => {
436
+ if (!slot.start_time || !slot.end_time) return;
437
+ if (repeatType === 'none' && startDate) {
438
+ const range = normalizeAbsoluteSessionRange({
439
+ startAt: `${startDate} ${slot.start_time}`,
440
+ endAt: `${startDate} ${slot.end_time}`,
441
+ scheduleId,
442
+ timeSlotId: toNumberId(slot.id),
443
+ source: 'schedule_time_slot',
444
+ fixedOffsetMinutes
445
+ });
446
+ if (range) ranges.push(range);
447
+ return;
448
+ }
449
+ addRule({
450
+ idSuffix: `slot-${String(slot.id ?? index)}`,
451
+ startTime: slot.start_time,
452
+ endTime: slot.end_time,
453
+ timeSlotId: toNumberId(slot.id)
454
+ });
455
+ });
456
+ return {
457
+ rules,
458
+ ranges
459
+ };
460
+ }
461
+ if (startAt && endAt) {
462
+ if (repeatType === 'none') {
463
+ const range = normalizeAbsoluteSessionRange({
464
+ startAt,
465
+ endAt,
466
+ scheduleId,
467
+ source: 'schedule_standard',
468
+ fixedOffsetMinutes
469
+ });
470
+ if (range) ranges.push(range);
471
+ } else {
472
+ try {
473
+ addRule({
474
+ idSuffix: 'standard',
475
+ startTime: (0, _localClock.formatLocalTime)(String(startAt), false),
476
+ endTime: (0, _localClock.formatLocalTime)(String(endAt), false)
477
+ });
478
+ } catch {
479
+ // Invalid schedule boundaries do not produce a recurring rule.
480
+ }
481
+ }
482
+ }
483
+ return {
484
+ rules,
485
+ ranges
486
+ };
487
+ }
488
+ function resolveProductScheduleItems(product, scheduleList) {
489
+ const scheduleMap = new Map(scheduleList.map(schedule => [String(schedule.id), schedule]));
490
+ const rawSchedules = [product.schedules, product.schedule, product._schedule].flatMap(value => Array.isArray(value) ? value : value ? [value] : []);
491
+ const explicitIds = uniqueAvailabilityIds([...(Array.isArray(product['schedule.ids']) ? product['schedule.ids'] : []), ...(Array.isArray(product.schedule_ids) ? product.schedule_ids : []), ...rawSchedules.filter(item => typeof item !== 'object')]);
492
+ const result = new Map();
493
+ rawSchedules.filter(item => item && typeof item === 'object').forEach(schedule => {
494
+ result.set(String(schedule.id ?? JSON.stringify(schedule)), schedule);
495
+ });
496
+ explicitIds.forEach(id => {
497
+ const schedule = scheduleMap.get(String(id));
498
+ if (schedule) result.set(String(id), schedule);
499
+ });
500
+ return Array.from(result.values());
193
501
  }
194
502
 
195
- /** Converts a ProductList record into the pure input shape consumed by ResourcePlanner. */
196
- function normalizeProductForPlanner(product) {
503
+ /** Converts ProductList and schedule records into the pure Availability vocabulary. */
504
+ function normalizeProductForAvailability(product, scheduleList = [], fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)()) {
197
505
  const raw = product;
198
506
  const kind = resolveProductKind(raw);
507
+ const isFlexibleDuration = kind === 'duration' && (0, _utils.isProductFlexibleDuration)(raw.duration);
508
+ const sessionRules = [];
509
+ const sessionRanges = [];
510
+ const explicitSession = raw.cartDetailValue?.session || raw._extend?.other?.session || raw.session;
511
+ const explicitRange = normalizeAbsoluteSessionRange({
512
+ startAt: explicitSession?.start_at,
513
+ endAt: explicitSession?.end_at,
514
+ scheduleId: toNumberId(explicitSession?.schedule_id),
515
+ timeSlotId: toNumberId(explicitSession?.time_slot_id),
516
+ source: 'product_session',
517
+ fixedOffsetMinutes
518
+ });
519
+ if (explicitRange) sessionRanges.push(explicitRange);
520
+ resolveProductScheduleItems(raw, scheduleList).forEach(schedule => {
521
+ const normalized = normalizeScheduleItem(schedule, fixedOffsetMinutes);
522
+ sessionRules.push(...normalized.rules);
523
+ sessionRanges.push(...normalized.ranges);
524
+ });
199
525
  return {
200
526
  id: raw.id ?? raw.product_id,
201
527
  title: pickLocalizedText(raw.title || raw.name || raw.product_title),
202
528
  kind,
203
- durationMinutes: Number(raw.duration?.value || raw.duration || raw.service_duration || 30) || 30,
204
- schedule: normalizeSchedule(raw),
205
- resourceRequirements: normalizeRequirements(raw),
206
- capacityRequired: Number(raw.capacity?.default || raw.capacity || 1) || 1,
207
- quantity: Number(raw.num || raw.quantity || 1) || 1,
208
- price: raw.price ?? raw.selling_price ?? raw.base_price,
529
+ isFlexibleDuration: isFlexibleDuration || undefined,
530
+ durationMinutes: kind === 'duration' && !isFlexibleDuration ? Math.max(1, Number(raw.duration?.value || raw.duration || raw.service_duration || 30) || 30) : undefined,
531
+ requirementGroups: normalizeRequirementGroups(raw),
532
+ sessionRules,
533
+ sessionRanges,
534
+ capacityRequired: Math.max(1, Number(raw.capacity?.default || raw.capacity || 1) || 1),
535
+ quantity: Math.max(1, Number(raw.num || raw.quantity || 1) || 1),
536
+ cutOffPolicy: normalizeAvailabilityCutOffPolicy(raw.cut_off_time),
209
537
  raw
210
538
  };
211
539
  }
212
- function normalizeResourceType(value) {
213
- if (value === 'multiple' || value === 'capacity') return value;
214
- return 'single';
540
+ function normalizeAvailabilityCutOffNumber(value, context) {
541
+ if (value == null) {
542
+ return {
543
+ reason: `${context} 缺失`
544
+ };
545
+ }
546
+ if (typeof value === 'string' && !value.trim()) {
547
+ return {
548
+ reason: `${context} 缺失`
549
+ };
550
+ }
551
+ const normalized = Number(value);
552
+ if (!Number.isFinite(normalized) || normalized < 0) {
553
+ return {
554
+ reason: `${context} 必须是非负有限数`
555
+ };
556
+ }
557
+ return {
558
+ value: normalized
559
+ };
560
+ }
561
+ function normalizeAvailabilityCutOffInteger(value, context) {
562
+ const numeric = normalizeAvailabilityCutOffNumber(value, context);
563
+ if (numeric.reason) return numeric;
564
+ if (!Number.isInteger(numeric.value)) {
565
+ return {
566
+ reason: `${context} 必须是非负整数`
567
+ };
568
+ }
569
+ return {
570
+ value: numeric.value
571
+ };
572
+ }
573
+ function invalidAvailabilityCutOffPolicy(reason) {
574
+ return {
575
+ type: 'invalid',
576
+ reason
577
+ };
578
+ }
579
+ function normalizeAvailabilityFutureDay(raw) {
580
+ if (!Object.prototype.hasOwnProperty.call(raw, 'future_day')) return {};
581
+ const futureDay = normalizeAvailabilityCutOffInteger(raw.future_day, 'cut_off_time.future_day');
582
+ return futureDay.reason ? {
583
+ reason: futureDay.reason
584
+ } : {
585
+ futureDay: futureDay.value
586
+ };
587
+ }
588
+ function normalizeAvailabilityCutOffPolicy(raw) {
589
+ if (raw == null || raw === '') return undefined;
590
+ if (!isRecord(raw)) {
591
+ return invalidAvailabilityCutOffPolicy('cut_off_time 必须是对象');
592
+ }
593
+ const futureDay = normalizeAvailabilityFutureDay(raw);
594
+ if (futureDay.reason) {
595
+ return invalidAvailabilityCutOffPolicy(futureDay.reason);
596
+ }
597
+ const type = String(raw.type ?? '').trim();
598
+ if (!type) {
599
+ return invalidAvailabilityCutOffPolicy('cut_off_time.type 缺失');
600
+ }
601
+ if (type === 'ongoing') {
602
+ if (!isRecord(raw.ongoing)) {
603
+ return invalidAvailabilityCutOffPolicy('cut_off_time.ongoing 缺失');
604
+ }
605
+ const ongoingType = String(raw.ongoing.type ?? '').trim();
606
+ if (ongoingType === 'before_end') {
607
+ return {
608
+ type: 'ongoing',
609
+ mode: 'before_end',
610
+ ...(futureDay.futureDay === undefined ? {} : {
611
+ futureDay: futureDay.futureDay
612
+ })
613
+ };
614
+ }
615
+ if (ongoingType === 'after_start') {
616
+ const graceMinutes = normalizeAvailabilityCutOffNumber(raw.ongoing.unit, 'cut_off_time.ongoing.unit');
617
+ return graceMinutes.reason ? invalidAvailabilityCutOffPolicy(graceMinutes.reason) : {
618
+ type: 'ongoing',
619
+ mode: 'after_start',
620
+ graceMinutes: graceMinutes.value,
621
+ ...(futureDay.futureDay === undefined ? {} : {
622
+ futureDay: futureDay.futureDay
623
+ })
624
+ };
625
+ }
626
+ return invalidAvailabilityCutOffPolicy(`未知的 cut_off_time.ongoing.type: ${ongoingType}`);
627
+ }
628
+ if (type === 'before_start') {
629
+ return {
630
+ type: 'before_start',
631
+ ...(futureDay.futureDay === undefined ? {} : {
632
+ futureDay: futureDay.futureDay
633
+ })
634
+ };
635
+ }
636
+ if (type === 'advance') {
637
+ const unit = normalizeAvailabilityCutOffNumber(raw.unit, 'cut_off_time.unit');
638
+ if (unit.reason) return invalidAvailabilityCutOffPolicy(unit.reason);
639
+ const unitType = String(raw.unit_type ?? '').trim();
640
+ if (!['days', 'hours', 'minutes'].includes(unitType)) {
641
+ return invalidAvailabilityCutOffPolicy('cut_off_time.unit_type 必须是 days|hours|minutes');
642
+ }
643
+ return {
644
+ type: 'advance',
645
+ unit: unit.value,
646
+ unitType: unitType,
647
+ ...(futureDay.futureDay === undefined ? {} : {
648
+ futureDay: futureDay.futureDay
649
+ })
650
+ };
651
+ }
652
+ return invalidAvailabilityCutOffPolicy(`未知的 cut_off_time.type: ${type}`);
215
653
  }
216
- function normalizeResource(raw) {
654
+ function normalizeResource(raw, fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)()) {
217
655
  const id = raw.resourceId ?? raw.resource_id ?? raw.id;
218
656
  if (id === undefined || id === null || id === '') return null;
219
- const combinedResourceIds = raw.combined_resource?.resource_ids;
657
+ const rawWindows = raw.windows || raw.times || [];
220
658
  return {
221
659
  id,
222
660
  formId: raw.formId ?? raw.form_id ?? raw.resource_form_id,
223
661
  name: raw.resourceName || raw.main_field || raw.name || raw.title,
224
- resourceType: normalizeResourceType(raw.type || raw.resourceType || raw.select_type?.type),
225
- capacity: Number(raw.capacity || raw.max_capacity || 1) || 1,
226
- times: Array.isArray(raw.times) ? raw.times : [],
227
- childResourceIds: Array.isArray(combinedResourceIds) ? combinedResourceIds : undefined,
662
+ code: raw.code,
663
+ resourceType: normalizeResourceType(raw.type || raw.resourceType),
664
+ capacity: Math.max(1, Number(raw.capacity || raw.max_capacity || 1) || 1),
665
+ windows: (Array.isArray(rawWindows) ? rawWindows : []).flatMap(window => {
666
+ const range = normalizeAvailabilityLocalDateTimeRange({
667
+ startAt: window.startAt || window.start_at || window.start,
668
+ endAt: window.endAt || window.end_at || window.end,
669
+ fixedOffsetMinutes,
670
+ rollOverEnd: true
671
+ });
672
+ if (!range) return [];
673
+ return [{
674
+ startAt: range.startAt,
675
+ endAt: range.endAt,
676
+ source: window.source,
677
+ raw: window
678
+ }];
679
+ }),
228
680
  raw
229
681
  };
230
682
  }
@@ -232,79 +684,62 @@ function normalizeScheduleIds(raw) {
232
684
  const ids = raw.schedule ?? raw.schedule_ids ?? raw.scheduleIds ?? [];
233
685
  return (Array.isArray(ids) ? ids : [ids]).map(id => toNumberId(id)).filter(id => id !== undefined);
234
686
  }
235
- function collectRawScheduleIds(rawResources) {
236
- const ids = new Map();
237
- (rawResources || []).forEach(resource => {
238
- normalizeScheduleIds(resource).forEach(id => {
239
- ids.set(String(id), id);
240
- });
241
- });
242
- return Array.from(ids.values());
243
- }
244
- function scheduleListHasIds(scheduleList, scheduleIds) {
245
- if (!scheduleIds.length) return true;
246
- const existingIds = new Set((scheduleList || []).map(schedule => String(schedule.id)));
247
- return scheduleIds.every(id => existingIds.has(String(id)));
248
- }
249
687
  function dateRangeDays(dateRange) {
250
- if (!dateRange?.start || !dateRange?.end) return [];
688
+ if (!dateRange?.startDate || !dateRange?.endDate) return [];
251
689
  const dates = [];
252
- let cursor = (0, _dayjs.default)(dateRange.start);
253
- const end = (0, _dayjs.default)(dateRange.end);
254
- if (!cursor.isValid() || !end.isValid() || end.isBefore(cursor, 'day')) return dates;
255
- while (cursor.isSame(end, 'day') || cursor.isBefore(end, 'day')) {
256
- dates.push(cursor.format('YYYY-MM-DD'));
257
- cursor = cursor.add(1, 'day');
690
+ const startScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(dateRange.startDate));
691
+ const endScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(dateRange.endDate));
692
+ if (endScalar < startScalar) return dates;
693
+ for (let cursor = startScalar; cursor <= endScalar; cursor = (0, _localClock.addLocalCalendarDays)(cursor, 1)) {
694
+ dates.push((0, _localClock.formatLocalDate)(cursor));
258
695
  }
259
696
  return dates;
260
697
  }
261
- function normalizePlannerEvent(rawEvent, resourceId) {
262
- const startAt = rawEvent.start_at ?? rawEvent.startAt ?? rawEvent.start_time ?? rawEvent.startTime;
263
- const endAt = rawEvent.end_at ?? rawEvent.endAt ?? rawEvent.end_time ?? rawEvent.endTime;
264
- if (!startAt || !endAt) return null;
265
- const rawSource = rawEvent.source;
266
- const source = rawSource === 'remote' || rawSource === 'cart' || rawSource === 'selection' ? rawSource : 'remote';
698
+ function normalizeResourceOccupancy(rawEvent, resourceId, source = 'remote', fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)()) {
699
+ const range = normalizeAvailabilityLocalDateTimeRange({
700
+ startAt: rawEvent.start_at ?? rawEvent.startAt ?? rawEvent.start_time ?? rawEvent.startTime,
701
+ endAt: rawEvent.end_at ?? rawEvent.endAt ?? rawEvent.end_time ?? rawEvent.endTime,
702
+ fixedOffsetMinutes,
703
+ rollOverEnd: false
704
+ });
705
+ if (!range) return null;
267
706
  return {
268
- ...rawEvent,
707
+ id: rawEvent.id == null ? undefined : String(rawEvent.id),
269
708
  resourceId,
270
- resource_id: rawEvent.resource_id ?? rawEvent.resourceId ?? resourceId,
271
- start_at: startAt,
272
- end_at: endAt,
709
+ startAt: range.startAt,
710
+ endAt: range.endAt,
273
711
  pax: Number(rawEvent.pax ?? rawEvent.number ?? rawEvent.capacity ?? 1) || 1,
274
- source
712
+ source: rawEvent.source === 'cart' || rawEvent.source === 'selection' ? rawEvent.source : source,
713
+ scheduleEventId: toNumberId(rawEvent.schedule_event_id ?? rawEvent.scheduleEventId),
714
+ serverEventId: toNumberId(rawEvent.schedule_event_id ?? rawEvent.server_event_id ?? rawEvent.id),
715
+ bookingUid: rawEvent.booking_uid ?? rawEvent.bookingUid ?? rawEvent.metadata?.unique_identification_number,
716
+ productUid: rawEvent.product_uid ?? rawEvent.productUid ?? rawEvent.metadata?.product_uid,
717
+ raw: rawEvent
275
718
  };
276
719
  }
277
- function normalizeResourceV2(raw) {
278
- const normalized = normalizeResource({
279
- ...raw,
280
- times: []
281
- });
282
- if (!normalized) return null;
283
- return normalized;
284
- }
285
720
  function buildResourceWindowFallbackTimes(params) {
286
721
  const {
287
722
  raw,
288
723
  dates,
289
724
  scheduleIds
290
725
  } = params;
291
- const rawStart = raw.start_time || raw.start_at || raw.startTime;
292
- const rawEnd = raw.end_time || raw.end_at || raw.endTime;
293
- if (!rawStart || !rawEnd) return [];
294
- const startBoundary = (0, _dayjs.default)(rawStart);
295
- const endBoundary = (0, _dayjs.default)(rawEnd);
296
- if (!startBoundary.isValid() || !endBoundary.isValid() || !endBoundary.isAfter(startBoundary)) {
297
- return [];
298
- }
726
+ const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
727
+ const boundary = normalizeAvailabilityLocalDateTimeRange({
728
+ startAt: raw.start_time || raw.start_at || raw.startTime,
729
+ endAt: raw.end_time || raw.end_at || raw.endTime,
730
+ fixedOffsetMinutes,
731
+ rollOverEnd: true
732
+ });
733
+ if (!boundary) return [];
299
734
  return dates.flatMap(date => {
300
- const dayStart = (0, _dayjs.default)(`${date} 00:00:00`);
301
- const dayEnd = (0, _dayjs.default)(`${date} 23:59:59`);
302
- const start = startBoundary.isAfter(dayStart) ? startBoundary : dayStart;
303
- const end = endBoundary.isBefore(dayEnd) ? endBoundary : dayEnd;
304
- if (!end.isAfter(start)) return [];
735
+ const dayStart = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(`${date}T00:00:00`));
736
+ const dayEnd = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(`${date}T23:59:59`));
737
+ const start = Math.max(boundary.startScalar, dayStart);
738
+ const end = Math.min(boundary.endScalar, dayEnd);
739
+ if (end <= start) return [];
305
740
  return [{
306
- start_at: start.format('YYYY-MM-DD HH:mm:ss'),
307
- end_at: end.format('YYYY-MM-DD HH:mm:ss'),
741
+ start_at: (0, _localClock.formatLocalDateTime)(start, fixedOffsetMinutes),
742
+ end_at: (0, _localClock.formatLocalDateTime)(end, fixedOffsetMinutes),
308
743
  schedule_ids: scheduleIds,
309
744
  source: 'resource_window_fallback'
310
745
  }];
@@ -313,16 +748,18 @@ function buildResourceWindowFallbackTimes(params) {
313
748
 
314
749
  /**
315
750
  * Materializes resource work windows locally from v2's schedule ids, then keeps its flat
316
- * event_list as planner events. This deliberately avoids the older API's times -> event_list
751
+ * event_list as resource occupancy records. This deliberately avoids the older API's times -> event_list
317
752
  * response nesting and lets one resource response serve any requested date range.
318
753
  */
319
- function buildPlannerResourcesFromV2(params) {
754
+ function buildAvailabilityResourcesFromV2(params) {
320
755
  const scheduleMap = new Map((params.scheduleList || []).map(schedule => [String(schedule.id), schedule]));
321
756
  const dates = dateRangeDays(params.dateRange);
322
- const externalEvents = [];
757
+ const resourceOccupancies = [];
758
+ const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
323
759
  const resources = (params.rawResources || []).map(raw => {
324
- const resource = normalizeResourceV2(raw);
760
+ const resource = normalizeResource(raw, fixedOffsetMinutes);
325
761
  if (!resource) return null;
762
+ const explicitWindows = [...resource.windows];
326
763
  const scheduleIds = normalizeScheduleIds(raw);
327
764
  const schedules = scheduleIds.map(id => scheduleMap.get(String(id))).filter(Boolean);
328
765
  const matchedScheduleIds = new Set(schedules.map(schedule => String(schedule.id)));
@@ -330,36 +767,55 @@ function buildPlannerResourcesFromV2(params) {
330
767
  if (missingScheduleIds.length > 0) {
331
768
  resource.raw = {
332
769
  ...(resource.raw || raw),
333
- _plannerMissingScheduleIds: missingScheduleIds
770
+ _availabilityMissingScheduleIds: missingScheduleIds
334
771
  };
335
772
  }
336
- resource.times = dates.flatMap(date => (0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, schedules).map(slot => ({
337
- start_at: (0, _dayjs.default)(slot.start_at).format('YYYY-MM-DD HH:mm:ss'),
338
- end_at: (0, _dayjs.default)(slot.end_at).format('YYYY-MM-DD HH:mm:ss'),
339
- schedule_ids: scheduleIds
773
+ const materializedWindows = dates.flatMap(date => schedules.flatMap(schedule => (0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, [schedule]).flatMap(slot => {
774
+ const range = normalizeAvailabilityLocalDateTimeRange({
775
+ startAt: slot.start_at,
776
+ endAt: slot.end_at,
777
+ fixedOffsetMinutes,
778
+ rollOverEnd: true
779
+ });
780
+ if (!range) return [];
781
+ return [{
782
+ startAt: range.startAt,
783
+ endAt: range.endAt,
784
+ source: `resource_schedule:${schedule.id}`,
785
+ raw: {
786
+ scheduleId: schedule.id
787
+ }
788
+ }];
340
789
  })));
341
- if (resource.times.length === 0 && missingScheduleIds.length > 0) {
342
- resource.times = buildResourceWindowFallbackTimes({
790
+ resource.windows = materializedWindows.length > 0 ? materializedWindows : explicitWindows;
791
+ if (resource.windows.length === 0 && missingScheduleIds.length > 0) {
792
+ resource.windows = buildResourceWindowFallbackTimes({
343
793
  raw,
344
794
  dates,
345
- scheduleIds
346
- });
795
+ scheduleIds,
796
+ fixedOffsetMinutes
797
+ }).map(window => ({
798
+ startAt: window.start_at,
799
+ endAt: window.end_at,
800
+ source: window.source,
801
+ raw: window
802
+ }));
347
803
  }
348
804
  const eventList = Array.isArray(raw.event_list) ? raw.event_list : [];
349
805
  eventList.forEach(event => {
350
- const normalizedEvent = normalizePlannerEvent(event, resource.id);
351
- if (normalizedEvent) externalEvents.push(normalizedEvent);
806
+ const occupancy = normalizeResourceOccupancy(event, resource.id, 'remote', fixedOffsetMinutes);
807
+ if (occupancy) resourceOccupancies.push(occupancy);
352
808
  });
353
809
  return resource;
354
810
  }).filter(resource => resource !== null);
355
811
  return {
356
812
  resources,
357
- externalEvents
813
+ resourceOccupancies
358
814
  };
359
815
  }
360
816
 
361
817
  /** Combines URL-locked resources with every resource referenced by the selected products. */
362
- function collectPlannerResourceIds(products, explicitResourceIds = []) {
818
+ function collectAvailabilityResourceIds(products, explicitResourceIds = []) {
363
819
  const ids = new Map();
364
820
  const add = value => {
365
821
  if (value === undefined || value === null || value === '') return;
@@ -367,80 +823,340 @@ function collectPlannerResourceIds(products, explicitResourceIds = []) {
367
823
  };
368
824
  explicitResourceIds.forEach(add);
369
825
  products.forEach(product => {
370
- product.resourceRequirements?.forEach(requirement => {
826
+ product.requirementGroups?.forEach(requirement => {
371
827
  requirement.resourceIds?.forEach(add);
372
828
  });
373
829
  });
374
830
  return Array.from(ids.values());
375
831
  }
376
- function samePlannerId(left, right) {
377
- if (left === undefined || right === undefined) return false;
378
- return String(left) === String(right);
832
+ function getProductLineUid(product) {
833
+ return String(product?.metadata?.unique_identification_number ?? product?.unique_identification_number ?? '');
379
834
  }
380
- function findRequirementForAssignment(product, assignment) {
381
- const requirements = product.resourceRequirements || [];
382
- return requirements.find(requirement => (requirement.resourceIds || []).some(id => samePlannerId(id, assignment.resourceId))) || (requirements.length === 1 ? requirements[0] : undefined) || requirements.find(requirement => requirement.required) || requirements[0];
835
+ function buildAvailabilityLineIdentity(product) {
836
+ return {
837
+ product_id: product.product_id,
838
+ product_variant_id: product.product_variant_id ?? 0,
839
+ unique_identification_number: getProductLineUid(product),
840
+ product_sku: product.product_sku,
841
+ product_bundle: product.product_bundle
842
+ };
383
843
  }
384
-
385
- /** Convenience wrapper for the usual one-resource booking case. */
386
- function buildBookingFromAssignment(params) {
387
- return buildBookingFromAssignments({
844
+ function buildBookingFromAvailabilityAssignment(params) {
845
+ return buildBookingFromAvailabilityAssignments({
846
+ product: params.product,
388
847
  assignments: [params.assignment],
848
+ timezone: params.timezone,
849
+ fixedOffsetMinutes: params.fixedOffsetMinutes
850
+ });
851
+ }
852
+ function buildBookingFromAvailabilityAssignments(params) {
853
+ const normalizedAssignments = (params.assignments || []).map(assignment => {
854
+ const resourceId = assignment.resourceId ?? assignment.resource_id;
855
+ const matchedGroup = (params.product.requirementGroups || []).find(group => group.resourceIds?.some(id => sameAvailabilityId(id, resourceId)));
856
+ return {
857
+ productId: assignment.productId ?? assignment.product_id,
858
+ resourceId,
859
+ formId: assignment.formId ?? assignment.form_id ?? matchedGroup?.formId,
860
+ startAt: assignment.startAt ?? assignment.start_at,
861
+ endAt: assignment.endAt ?? assignment.end_at,
862
+ capacityRequired: assignment.capacityRequired ?? assignment.capacity_required ?? matchedGroup?.capacityRequired,
863
+ scheduleId: assignment.scheduleId ?? assignment.schedule_id,
864
+ timeSlotId: assignment.timeSlotId ?? assignment.time_slot_id
865
+ };
866
+ });
867
+ const first = normalizedAssignments[0];
868
+ const fixedOffsetMinutes = params.fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)();
869
+ const range = normalizeAvailabilityLocalDateTimeRange({
870
+ startAt: first.startAt,
871
+ endAt: first.endAt,
872
+ fixedOffsetMinutes,
873
+ rollOverEnd: false
874
+ });
875
+ if (!range) {
876
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'assignment 时间范围非法', {
877
+ assignments: params.assignments
878
+ });
879
+ }
880
+ const candidate = {
881
+ key: `${String(first.productId)}:${range.startAt}:${range.endAt}`,
882
+ productId: first.productId,
883
+ source: 'duration_window',
884
+ startAt: range.startAt,
885
+ endAt: range.endAt,
886
+ bookingStartAt: range.startAt,
887
+ bookingEndAt: range.endAt,
888
+ date: range.startAt.slice(0, 10),
889
+ startTime: (0, _localClock.formatLocalTime)(range.startAt, false),
890
+ endTime: (0, _localClock.formatLocalTime)(range.endAt, false),
891
+ timezone: String(params.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'runtime'),
892
+ scheduleRefs: [],
893
+ primaryScheduleRef: undefined
894
+ };
895
+ return buildBookingFromAvailabilitySelection({
896
+ assignments: normalizedAssignments,
897
+ candidate,
389
898
  product: params.product
390
899
  });
391
900
  }
392
-
393
- /**
394
- * Converts all resources needed by one product/time interval into the one booking expected by
395
- * OrderModule. Callers must group assignments before this function so a multi-form booking does
396
- * not become several competing bookings.
397
- */
398
- function buildBookingFromAssignments(params) {
901
+ function getBookingUid(booking) {
902
+ return String(booking?.metadata?.unique_identification_number ?? booking?.booking_uid ?? '');
903
+ }
904
+ function stableValue(value) {
905
+ if (Array.isArray(value)) return value.map(stableValue);
906
+ if (!value || typeof value !== 'object') return value;
907
+ return Object.keys(value).sort().reduce((result, key) => {
908
+ if (value[key] !== undefined && typeof value[key] !== 'function') {
909
+ result[key] = stableValue(value[key]);
910
+ }
911
+ return result;
912
+ }, {});
913
+ }
914
+ function hashText(value) {
915
+ let hash = 2166136261;
916
+ for (let index = 0; index < value.length; index += 1) {
917
+ hash ^= value.charCodeAt(index);
918
+ hash = Math.imul(hash, 16777619);
919
+ }
920
+ return (hash >>> 0).toString(36);
921
+ }
922
+ function resolveOrderRevision(tempOrder) {
923
+ if (!tempOrder) return 'order:none';
924
+ const products = (Array.isArray(tempOrder.products) ? tempOrder.products : []).map(product => ({
925
+ uid: getProductLineUid(product),
926
+ productId: product.product_id ?? product.id,
927
+ variantId: product.product_variant_id ?? 0,
928
+ num: product.num ?? product.quantity ?? 1,
929
+ bookingUid: product.booking_uid ?? product.metadata?.booking_uid,
930
+ productSku: product.product_sku,
931
+ productBundle: product.product_bundle
932
+ })).sort((left, right) => left.uid.localeCompare(right.uid));
933
+ const bookings = (Array.isArray(tempOrder.bookings) ? tempOrder.bookings : []).map(booking => ({
934
+ uid: getBookingUid(booking),
935
+ scheduleEventId: booking.schedule_event_id,
936
+ productUid: booking.product_uid,
937
+ scheduleId: booking.schedule_id,
938
+ startDate: booking.start_date,
939
+ startTime: booking.start_time,
940
+ endDate: booking.end_date,
941
+ endTime: booking.end_time,
942
+ resources: booking.resources
943
+ })).sort((left, right) => left.uid.localeCompare(right.uid));
944
+ return `order:${hashText(JSON.stringify(stableValue({
945
+ products,
946
+ bookings
947
+ })))}`;
948
+ }
949
+ function buildBookingFromAvailabilitySelection(params) {
399
950
  const {
400
951
  assignments,
401
- product
952
+ candidate,
953
+ product,
954
+ resources = [],
955
+ bookingUid,
956
+ holder,
957
+ holderIds
402
958
  } = params;
403
- const firstAssignment = assignments[0];
404
- const start = (0, _dayjs.default)(firstAssignment.start_at);
405
- const end = (0, _dayjs.default)(firstAssignment.end_at);
959
+ const bookingStartAt = candidate.bookingStartAt || candidate.startAt;
960
+ const bookingEndAt = candidate.bookingEndAt || candidate.endAt;
961
+ const startScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(bookingStartAt));
962
+ const endScalar = (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(bookingEndAt));
963
+ const scheduleId = candidate.primaryScheduleRef?.scheduleId ?? assignments.find(assignment => assignment.scheduleId != null)?.scheduleId;
964
+ const timeSlotId = candidate.primaryScheduleRef?.timeSlotId ?? assignments.find(assignment => assignment.timeSlotId != null)?.timeSlotId;
965
+ if (product.kind === 'session' && scheduleId == null) {
966
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', 'session 候选缺少真实 schedule identity', {
967
+ productId: product.id,
968
+ candidateKey: candidate.key,
969
+ scheduleRefs: candidate.scheduleRefs
970
+ });
971
+ }
972
+ const normalizedHolder = holder && typeof holder === 'object' ? (0, _lodashEs.cloneDeep)(holder) : undefined;
973
+ const normalizedHolderIds = Array.isArray(holderIds) ? (0, _lodashEs.cloneDeep)(holderIds) : holderIds;
406
974
  return {
407
- start_date: start.format('YYYY-MM-DD'),
408
- start_time: start.format('HH:mm'),
409
- end_date: end.format('YYYY-MM-DD'),
410
- end_time: end.format('HH:mm'),
411
- duration: Math.max(1, end.diff(start, 'minute')),
975
+ start_date: bookingStartAt.slice(0, 10),
976
+ start_time: bookingStartAt.slice(11, 16),
977
+ end_date: bookingEndAt.slice(0, 10),
978
+ end_time: bookingEndAt.slice(11, 16),
979
+ duration: Math.max(1, Math.trunc((endScalar - startScalar) / (60 * 1000))),
412
980
  like_status: 'common',
413
- schedule_id: 0,
981
+ schedule_id: scheduleId ?? 0,
982
+ ...(normalizedHolder ? {
983
+ holder: normalizedHolder
984
+ } : {}),
414
985
  resources: assignments.map(assignment => {
415
- const requirement = findRequirementForAssignment(product, assignment);
986
+ // requirementSelections 的提交协议只携带资源 ID;购物车展示则需要名称。
987
+ // commit 此时仍持有本次不可变 projection,因此直接按 ID 取回名称并
988
+ // 固化到 booking,避免 UI 再依赖 legacy BookingContext resourcesOriginMap。
989
+ const matchedResource = resources.find(resource => sameAvailabilityId(resource.id, assignment.resourceId));
990
+ const resourceName = String(matchedResource?.name || '').trim();
416
991
  return {
417
992
  id: assignment.resourceId,
418
993
  resource_id: assignment.resourceId,
419
- form_id: requirement?.formId,
994
+ form_id: assignment.formId,
420
995
  relation_type: 'form',
421
996
  relation_id: assignment.resourceId,
422
997
  like_status: 'common',
423
- capacity: assignment.capacityRequired || product.capacityRequired || 1
998
+ capacity: assignment.capacityRequired || 1,
999
+ ...(resourceName ? {
1000
+ metadata: {
1001
+ resource_name: resourceName
1002
+ }
1003
+ } : {})
424
1004
  };
425
1005
  }),
426
1006
  metadata: {
1007
+ ...(bookingUid ? {
1008
+ unique_identification_number: bookingUid
1009
+ } : {}),
427
1010
  unified_booking_sales: true,
428
- planner_source: product.kind
1011
+ availability_candidate_key: candidate.key,
1012
+ availability_source: candidate.source,
1013
+ availability_slice_start_at: candidate.startAt,
1014
+ availability_slice_end_at: candidate.endAt,
1015
+ availability_schedule_refs: candidate.scheduleRefs,
1016
+ ...(timeSlotId == null ? {} : {
1017
+ time_slot_id: timeSlotId
1018
+ }),
1019
+ product_kind: product.kind,
1020
+ ...(normalizedHolderIds === undefined ? {} : {
1021
+ holder_id: normalizedHolderIds
1022
+ }),
1023
+ ...(normalizedHolder ? {
1024
+ holder: (0, _lodashEs.cloneDeep)(normalizedHolder)
1025
+ } : {})
429
1026
  }
430
1027
  };
431
1028
  }
432
- function buildAssignmentGroupKey(assignment) {
433
- return [assignment.productId, assignment.start_at, assignment.end_at].join(':');
1029
+ const POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES = new Set(['past', 'booking_cutoff', 'resource_full', 'resource_unavailable', 'resource_capacity_exceeded',
1030
+ // A required group whose selected resource is outside its window is surfaced
1031
+ // as missing_resource at group level. Structural validation below still
1032
+ // rejects a genuinely missing selection.
1033
+ 'missing_resource']);
1034
+ const POS_NO_RESOURCE_ID = 0;
1035
+ function matchesForcedSelectionGroup(selection, group) {
1036
+ const hasGroupId = typeof selection.requirementGroupId === 'string' && selection.requirementGroupId.trim().length > 0;
1037
+ const hasFormId = selection.formId !== undefined && selection.formId !== null;
1038
+ if (!hasGroupId && !hasFormId) return false;
1039
+ if (hasGroupId && selection.requirementGroupId !== group.id) return false;
1040
+ if (hasFormId && !sameAvailabilityId(selection.formId, group.formId)) return false;
1041
+ return true;
434
1042
  }
435
1043
 
436
- /** Builds the exact identity BaseSales needs to remove only this solution's order lines. */
437
- function buildPlannerLineIdentity(product) {
1044
+ /**
1045
+ * POS may intentionally overbook a configured time/resource. Rebuild the
1046
+ * assignments from the submitted, structurally valid resource identities so
1047
+ * a full/unavailable option is not silently dropped by normal availability
1048
+ * evaluation. This deliberately ignores capacity, occupancy and resource
1049
+ * windows, while retaining product/group/resource identity and selection
1050
+ * cardinality checks.
1051
+ */
1052
+ function buildPosForcedAvailabilityAssignments(params) {
1053
+ const {
1054
+ product,
1055
+ resources,
1056
+ candidate,
1057
+ requirementSelections,
1058
+ partySize
1059
+ } = params;
1060
+ const groups = product.requirementGroups || [];
1061
+ const conflicts = [];
1062
+ const assignments = [];
1063
+ const selectionsByGroup = new Map();
1064
+ const addConflict = (code, message, details = {}) => conflicts.push({
1065
+ code,
1066
+ message,
1067
+ productId: candidate.productId,
1068
+ candidateKey: candidate.key,
1069
+ ...details
1070
+ });
1071
+ requirementSelections.forEach(selection => {
1072
+ const matchedGroups = groups.filter(group => matchesForcedSelectionGroup(selection, group));
1073
+ if (matchedGroups.length !== 1) {
1074
+ addConflict('invalid_requirement_group', '选择的资源组不属于当前商品', {
1075
+ requirementGroupId: selection.requirementGroupId,
1076
+ formId: selection.formId
1077
+ });
1078
+ return;
1079
+ }
1080
+ const group = matchedGroups[0];
1081
+ if (selectionsByGroup.has(group.id)) {
1082
+ addConflict('invalid_selection_count', `资源组 ${group.id} 被重复提交`, {
1083
+ requirementGroupId: group.id,
1084
+ formId: group.formId
1085
+ });
1086
+ return;
1087
+ }
1088
+ selectionsByGroup.set(group.id, selection);
1089
+ if (!Array.isArray(selection.resourceIds)) {
1090
+ addConflict('invalid_selection_count', `资源组 ${group.id} 的资源选择必须是数组`, {
1091
+ requirementGroupId: group.id,
1092
+ formId: group.formId
1093
+ });
1094
+ return;
1095
+ }
1096
+ const selectedResourceIds = Array.from(new Map(selection.resourceIds.map(resourceId => [String(resourceId), resourceId])).values());
1097
+ const required = group.required !== false;
1098
+ const min = Math.max(required ? 1 : 0, Number(group.min ?? (required ? 1 : 0)) || 0);
1099
+ const max = Math.max(min, Number(group.max ?? Math.max(min, 1)) || Math.max(min, 1));
1100
+ if (selectedResourceIds.length < min) {
1101
+ addConflict('missing_resource', `资源组 ${group.id} 缺少已选资源`, {
1102
+ requirementGroupId: group.id,
1103
+ formId: group.formId
1104
+ });
1105
+ return;
1106
+ }
1107
+ if (selectedResourceIds.length > max) {
1108
+ addConflict('invalid_selection_count', `资源组 ${group.id} 已选资源数量超出上限`, {
1109
+ requirementGroupId: group.id,
1110
+ formId: group.formId
1111
+ });
1112
+ return;
1113
+ }
1114
+ selectedResourceIds.forEach(resourceId => {
1115
+ if (sameAvailabilityId(resourceId, POS_NO_RESOURCE_ID)) {
1116
+ assignments.push({
1117
+ productId: product.id,
1118
+ resourceId: POS_NO_RESOURCE_ID,
1119
+ formId: group.formId ?? selection.formId,
1120
+ startAt: candidate.bookingStartAt || candidate.startAt,
1121
+ endAt: candidate.bookingEndAt || candidate.endAt,
1122
+ capacityRequired: Math.max(1, partySize),
1123
+ scheduleId: candidate.primaryScheduleRef?.scheduleId,
1124
+ timeSlotId: candidate.primaryScheduleRef?.timeSlotId
1125
+ });
1126
+ return;
1127
+ }
1128
+ const resource = resources.find(item => sameAvailabilityId(item.id, resourceId));
1129
+ const belongsToGroup = Boolean(resource && !(group.formId !== undefined && resource.formId !== undefined && !sameAvailabilityId(group.formId, resource.formId)) && !(group.resourceIds?.length && !group.resourceIds.some(id => sameAvailabilityId(id, resourceId))));
1130
+ if (!resource || !belongsToGroup) {
1131
+ addConflict('resource_unavailable', `资源 ${String(resourceId)} 不属于当前商品资源组`, {
1132
+ requirementGroupId: group.id,
1133
+ formId: group.formId,
1134
+ resourceId
1135
+ });
1136
+ return;
1137
+ }
1138
+ assignments.push({
1139
+ productId: product.id,
1140
+ resourceId,
1141
+ formId: group.formId ?? resource.formId,
1142
+ startAt: candidate.bookingStartAt || candidate.startAt,
1143
+ endAt: candidate.bookingEndAt || candidate.endAt,
1144
+ capacityRequired: Math.max(1, partySize),
1145
+ scheduleId: candidate.primaryScheduleRef?.scheduleId,
1146
+ timeSlotId: candidate.primaryScheduleRef?.timeSlotId
1147
+ });
1148
+ });
1149
+ });
1150
+ groups.filter(group => group.required !== false).forEach(group => {
1151
+ if (selectionsByGroup.has(group.id)) return;
1152
+ addConflict('missing_resource', `资源组 ${group.id} 缺少已选资源`, {
1153
+ requirementGroupId: group.id,
1154
+ formId: group.formId
1155
+ });
1156
+ });
438
1157
  return {
439
- product_id: product.product_id,
440
- product_variant_id: product.product_variant_id ?? 0,
441
- unique_identification_number: product.metadata?.unique_identification_number || product.unique_identification_number,
442
- product_sku: product.product_sku,
443
- product_bundle: product.product_bundle
1158
+ assignments,
1159
+ conflicts
444
1160
  };
445
1161
  }
446
1162
  class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
@@ -451,55 +1167,375 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
451
1167
  openDataTarget = null;
452
1168
  loadOpenDataInFlight = null;
453
1169
  loadOpenDataInFlightTarget = null;
1170
+ availabilityRuntimeOffsetMinutes = null;
1171
+ availabilityScheduleCatalog = null;
1172
+ availabilityProjectionSequence = 0;
1173
+ availabilityContextSequence = 0;
1174
+ availabilityLifecycleGeneration = 0;
1175
+ availabilityContextRegistry = new Map();
1176
+ availabilityProjectionPool = new Map();
1177
+ availabilityPrepareInFlight = new Map();
1178
+ availabilityProjectionRefreshInFlight = new Map();
1179
+ availabilityRemoteOccupancyCache = new _remoteOccupancyCache.AvailabilityRemoteOccupancyCache(MAX_REMOTE_OCCUPANCY_RESOURCE_DATE_CELLS);
1180
+ availabilityRemoteResourceQueryInFlight = new Map();
1181
+ availabilityRemoteResourceQuerySequence = 0;
1182
+ availabilityRemoteResourceEpoch = 0;
1183
+ availabilityCommitLocks = new Set();
1184
+ allergyItemRuleEvaluator = new _itemRule.ItemRuleEvaluator();
1185
+ allergyItemRuleConfigs = [];
1186
+ allergyItemRuleConfigsPromise = null;
1187
+ async initialize(core, options = {}) {
1188
+ this.availabilityScheduleCatalog = null;
1189
+ this.openDataTarget = null;
1190
+ this.availabilityRuntimeOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)();
1191
+ this.clearAvailabilityRemoteResourceState();
1192
+ this.resetAllergyItemRuleState();
1193
+ await super.initialize(core, options);
1194
+ const openDataBusinessCode = String(this.otherParams?.businessCode ?? this.otherParams?.business_code ?? '').trim();
1195
+ const openDataChannel = String(this.otherParams?.channel ?? '').trim();
1196
+ if (this.otherParams?.openData && openDataBusinessCode && openDataChannel) {
1197
+ this.openDataTarget = `${openDataBusinessCode}+${openDataChannel}`;
1198
+ }
1199
+ this.captureAvailabilityScheduleCatalog();
1200
+ }
454
1201
  getSubmitOrderSalesChannel() {
455
1202
  const channel = String(this.otherParams?.channel ?? '').trim();
456
1203
  return channel || super.getSubmitOrderSalesChannel();
457
1204
  }
458
- isSessionStoragePersistEnabled() {
459
- return Boolean(this.cacheId && this.otherParams?.enableSessionStoragePersist === true);
1205
+ createAvailabilityFacadeError(code, message, details) {
1206
+ return new _ResourcePlanner.AvailabilityError(code, message, details);
460
1207
  }
461
- shouldUseSessionStorageForSubModule(moduleName) {
462
- return this.isSessionStoragePersistEnabled() && moduleName === 'order';
1208
+ getAvailabilityScopeDescriptor(otherParams = this.otherParams || {}) {
1209
+ return {
1210
+ cacheId: this.cacheId || '',
1211
+ businessCode: String(otherParams.businessCode ?? otherParams.business_code ?? ''),
1212
+ channel: String(otherParams.channel ?? ''),
1213
+ platform: String(otherParams.platform ?? ''),
1214
+ businessTimezone: String(otherParams.businessTimezone ?? '')
1215
+ };
463
1216
  }
464
-
465
- /**
466
- * 读取与当前 cacheId 同分桶的 Solution 会话状态。
467
- * 该状态独立于 otherParams,适合页面皮肤保存短生命周期 UI 状态。
468
- */
469
- getSessionStateValue(key) {
470
- if (!key || !this.isSessionStoragePersistEnabled()) return undefined;
471
- const cacheData = this.readSessionCacheData();
472
- const solutionCache = cacheData?.[this.name];
473
- if (!isRecord(solutionCache) || !isRecord(solutionCache.sessionState)) {
474
- return undefined;
475
- }
476
- return solutionCache.sessionState[key];
1217
+ normalizeAvailabilityIdList(values) {
1218
+ if (!Array.isArray(values) || values.length === 0) return undefined;
1219
+ const uniqueMap = new Map();
1220
+ values.forEach(value => {
1221
+ uniqueMap.set(String(value), value);
1222
+ });
1223
+ return Array.from(uniqueMap.entries()).sort(([left], [right]) => left.localeCompare(right)).map(([, value]) => value);
477
1224
  }
478
-
479
- /**
480
- * 写入与当前 cacheId 同分桶的 Solution 会话状态。
481
- * value 为 undefined 时删除该 key;存储失败不阻断业务流程。
482
- */
483
- setSessionStateValue(key, value) {
484
- if (!key || !this.isSessionStoragePersistEnabled()) return;
485
- const cacheData = this.readSessionCacheData();
486
- const solutionCache = cacheData?.[this.name];
487
- const currentSessionState = isRecord(solutionCache) && isRecord(solutionCache.sessionState) ? solutionCache.sessionState : {};
488
- const nextSessionState = {
489
- ...currentSessionState
490
- };
491
- if (value === undefined) {
492
- delete nextSessionState[key];
493
- } else {
494
- nextSessionState[key] = value;
1225
+ normalizeAvailabilityStringList(values) {
1226
+ if (!Array.isArray(values) || values.length === 0) return undefined;
1227
+ return Array.from(new Set(values.map(String))).sort((left, right) => left.localeCompare(right));
1228
+ }
1229
+ normalizeAvailabilityPrepareDescriptor(params) {
1230
+ this.assertAvailabilityPrepareParams(params);
1231
+ const timezone = String(params.timezone || this.otherParams?.businessTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai');
1232
+ assertValidAvailabilityTimezone(timezone);
1233
+ const dateRange = this.normalizeAvailabilityDateRange(params, timezone);
1234
+ const slotStepMinutes = params.slotStepMinutes || DEFAULT_AVAILABILITY_SLOT_STEP_MINUTES;
1235
+ const businessHours = params.businessHours || DEFAULT_AVAILABILITY_BUSINESS_HOURS;
1236
+ const mode = params.mode || 'appointment';
1237
+ if (!isValidClockTime(businessHours.startTime) || !isValidClockTime(businessHours.endTime)) {
1238
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'businessHours 必须使用 HH:mm', {
1239
+ businessHours
1240
+ });
495
1241
  }
496
- this.checkSaveCache({
497
- cacheId: this.cacheId,
498
- fatherModule: this.name,
499
- store: {
500
- sessionState: nextSessionState
1242
+ return this.cloneAvailabilityPrepareParams({
1243
+ ...params,
1244
+ productIds: this.normalizeAvailabilityIdList(params.productIds),
1245
+ resourceIds: this.normalizeAvailabilityIdList(params.resourceIds),
1246
+ editingProductLineUids: this.normalizeAvailabilityStringList(params.editingProductLineUids),
1247
+ dateRange,
1248
+ timezone,
1249
+ slotStepMinutes,
1250
+ businessHours: {
1251
+ ...businessHours
501
1252
  },
502
- cacheKey: ['sessionState']
1253
+ _availabilityOperatingHours: params._availabilityOperatingHours ? {
1254
+ source: params._availabilityOperatingHours.source,
1255
+ scheduleIds: [...params._availabilityOperatingHours.scheduleIds]
1256
+ } : undefined,
1257
+ mode
1258
+ });
1259
+ }
1260
+ async resolveAvailabilityPrepareDefaults(params) {
1261
+ const needsOperatingHours = params.businessHours === undefined;
1262
+ const needsTimeSlice = params.slotStepMinutes === undefined;
1263
+ if (!needsOperatingHours && !needsTimeSlice) {
1264
+ return {
1265
+ ...params,
1266
+ _availabilityOperatingHours: {
1267
+ source: 'prepare_business_hours',
1268
+ scheduleIds: []
1269
+ }
1270
+ };
1271
+ }
1272
+ const businessCode = String(this.otherParams?.businessCode ?? this.otherParams?.business_code ?? '').trim();
1273
+ const channel = String(this.otherParams?.channel ?? '').trim();
1274
+ const currentTarget = businessCode && channel ? `${businessCode}+${channel}` : null;
1275
+ let openData = !currentTarget || this.openDataTarget === currentTarget ? this.otherParams?.openData : null;
1276
+ if (!openData && this.store.openData) {
1277
+ try {
1278
+ openData = await this.getOpenData();
1279
+ } catch (error) {
1280
+ if (needsOperatingHours) {
1281
+ throw this.createAvailabilityFacadeError('OPERATING_HOURS_RESOLVE_FAILED', '无法读取店铺营业时间配置,请重试', {
1282
+ businessCode,
1283
+ channel,
1284
+ cause: error instanceof Error ? error.message : String(error)
1285
+ });
1286
+ }
1287
+ console.warn('[UnifiedBookingSales] OpenData 时间切片配置加载失败,回退为默认值', error);
1288
+ }
1289
+ }
1290
+ const operatingScheduleIds = this.normalizeAvailabilityIdList(Array.isArray(openData?.['availability.operating_hours']) ? openData?.['availability.operating_hours'] : undefined) || [];
1291
+ const configuredTimeSlice = needsTimeSlice ? normalizePositiveInteger(openData?.['reservation.time_slice']) : undefined;
1292
+ return {
1293
+ ...params,
1294
+ ...(configuredTimeSlice ? {
1295
+ slotStepMinutes: configuredTimeSlice
1296
+ } : {}),
1297
+ _availabilityOperatingHours: needsOperatingHours ? {
1298
+ source: operatingScheduleIds.length > 0 ? 'open_data_schedule' : 'default_business_hours',
1299
+ scheduleIds: operatingScheduleIds
1300
+ } : {
1301
+ source: 'prepare_business_hours',
1302
+ scheduleIds: []
1303
+ }
1304
+ };
1305
+ }
1306
+ buildAvailabilityPrepareKey(params) {
1307
+ const normalizedParams = this.normalizeAvailabilityPrepareDescriptor(params);
1308
+ return JSON.stringify(stableValue({
1309
+ scope: this.getAvailabilityScopeDescriptor(),
1310
+ prepare: normalizedParams
1311
+ }));
1312
+ }
1313
+ clearAvailabilityRemoteResourceState() {
1314
+ this.availabilityRemoteResourceEpoch += 1;
1315
+ this.availabilityRemoteOccupancyCache.clear();
1316
+ this.availabilityRemoteResourceQueryInFlight.clear();
1317
+ this.availabilityRemoteResourceQuerySequence = 0;
1318
+ }
1319
+ buildAvailabilityRemoteResourceScopeKey(timezoneName) {
1320
+ return JSON.stringify(stableValue({
1321
+ scope: this.getAvailabilityScopeDescriptor(),
1322
+ timezone: timezoneName
1323
+ }));
1324
+ }
1325
+ buildAvailabilityRemoteResourceQueryKey(params) {
1326
+ return JSON.stringify(stableValue({
1327
+ scope: this.getAvailabilityScopeDescriptor(),
1328
+ timezone: params.timezone,
1329
+ dateRange: params.dateRange,
1330
+ resourceIds: this.normalizeAvailabilityIdList(params.resourceIds) || []
1331
+ }));
1332
+ }
1333
+ async setOtherParams(params, {
1334
+ cover = false
1335
+ } = {}) {
1336
+ const previousScope = this.getAvailabilityScopeDescriptor(this.otherParams || {});
1337
+ const previousItemRuleConfig = this.otherParams?.itemRule;
1338
+ const explicitlySetsOpenData = Object.prototype.hasOwnProperty.call(params, 'openData');
1339
+ await super.setOtherParams(params, {
1340
+ cover
1341
+ });
1342
+ const nextScope = this.getAvailabilityScopeDescriptor(this.otherParams || {});
1343
+ if (explicitlySetsOpenData) {
1344
+ this.openDataTarget = this.otherParams?.openData && nextScope.businessCode && nextScope.channel ? `${nextScope.businessCode}+${nextScope.channel}` : null;
1345
+ }
1346
+ const shouldResetAvailabilityState = previousScope.cacheId !== nextScope.cacheId || previousScope.businessCode !== nextScope.businessCode || previousScope.channel !== nextScope.channel || previousScope.platform !== nextScope.platform || previousScope.businessTimezone !== nextScope.businessTimezone;
1347
+ if (shouldResetAvailabilityState) {
1348
+ await this.resetAvailabilityProjectionState('set_other_params_reset');
1349
+ }
1350
+ if (shouldResetAvailabilityState || explicitlySetsOpenData || previousItemRuleConfig !== this.otherParams?.itemRule) {
1351
+ this.resetAllergyItemRuleState();
1352
+ }
1353
+ }
1354
+ async destroy() {
1355
+ this.availabilityScheduleCatalog = null;
1356
+ this.availabilityRuntimeOffsetMinutes = null;
1357
+ await this.resetAvailabilityProjectionState('destroy');
1358
+ this.resetAllergyItemRuleState();
1359
+ await super.destroy();
1360
+ }
1361
+ createAvailabilityContextId() {
1362
+ this.availabilityContextSequence += 1;
1363
+ return `${this.name}:availability:${Date.now()}:${this.availabilityContextSequence}`;
1364
+ }
1365
+ buildAvailabilityProjectionKey(contextId, version) {
1366
+ return `${contextId}:${version}`;
1367
+ }
1368
+ getAvailabilityRuntimeOffsetMinutes() {
1369
+ if (this.availabilityRuntimeOffsetMinutes === null) {
1370
+ this.availabilityRuntimeOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)();
1371
+ }
1372
+ return this.availabilityRuntimeOffsetMinutes;
1373
+ }
1374
+ getAvailabilityRuntimeDateTime(value = new Date()) {
1375
+ return (0, _localClock.formatLocalDateTime)((0, _localClock.localDateToScalar)(value), this.getAvailabilityRuntimeOffsetMinutes());
1376
+ }
1377
+ cloneAvailabilityPrepareParams(params) {
1378
+ return (0, _lodashEs.cloneDeep)({
1379
+ ...params,
1380
+ productIds: params.productIds ? [...params.productIds] : undefined,
1381
+ resourceIds: params.resourceIds ? [...params.resourceIds] : undefined,
1382
+ rawProducts: params.rawProducts ? (0, _lodashEs.cloneDeep)(params.rawProducts) : undefined,
1383
+ rawResources: params.rawResources ? (0, _lodashEs.cloneDeep)(params.rawResources) : undefined,
1384
+ rawOccupancies: params.rawOccupancies ? (0, _lodashEs.cloneDeep)(params.rawOccupancies) : undefined,
1385
+ editingProductLineUids: params.editingProductLineUids ? [...params.editingProductLineUids] : undefined,
1386
+ dateRange: params.dateRange ? {
1387
+ ...params.dateRange
1388
+ } : undefined,
1389
+ businessHours: params.businessHours ? {
1390
+ ...params.businessHours
1391
+ } : undefined,
1392
+ _availabilityOperatingHours: params._availabilityOperatingHours ? {
1393
+ source: params._availabilityOperatingHours.source,
1394
+ scheduleIds: [...params._availabilityOperatingHours.scheduleIds]
1395
+ } : undefined,
1396
+ catalogScope: params.catalogScope?.type === 'schedule' ? {
1397
+ ...params.catalogScope
1398
+ } : params.catalogScope?.type === 'productList' ? {
1399
+ type: 'productList',
1400
+ params: {
1401
+ ...params.catalogScope.params
1402
+ }
1403
+ } : undefined
1404
+ });
1405
+ }
1406
+ getAvailabilityDurationCandidateAnchorAt(projection) {
1407
+ const value = projection?.meta.durationCandidateAnchorAt;
1408
+ return typeof value === 'string' && value.trim() ? value : undefined;
1409
+ }
1410
+ createAvailabilityContextRef(context) {
1411
+ return {
1412
+ contextId: context.contextId
1413
+ };
1414
+ }
1415
+ async emitAvailabilityDiagnostic(payload) {
1416
+ await this.core?.effects?.emit?.(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilityProjectionDiagnostic}`, {
1417
+ ...payload,
1418
+ registrySize: this.availabilityContextRegistry.size,
1419
+ projectionPoolSize: this.availabilityProjectionPool.size
1420
+ });
1421
+ }
1422
+ touchAvailabilityContext(context) {
1423
+ context.lastAccessedAt = new Date().toISOString();
1424
+ this.availabilityContextRegistry.delete(context.contextId);
1425
+ this.availabilityContextRegistry.set(context.contextId, context);
1426
+ }
1427
+ touchAvailabilityProjection(entry) {
1428
+ this.availabilityProjectionPool.delete(entry.projectionKey);
1429
+ this.availabilityProjectionPool.set(entry.projectionKey, entry);
1430
+ return entry;
1431
+ }
1432
+ removeAvailabilityProjection(projectionKey) {
1433
+ if (!projectionKey) return;
1434
+ this.availabilityProjectionPool.delete(projectionKey);
1435
+ }
1436
+ removeAvailabilityContext(contextId) {
1437
+ const context = this.availabilityContextRegistry.get(contextId);
1438
+ if (!context) return;
1439
+ this.removeAvailabilityProjection(context.activeProjectionKey);
1440
+ this.availabilityProjectionRefreshInFlight.delete(contextId);
1441
+ this.availabilityCommitLocks.delete(contextId);
1442
+ this.availabilityContextRegistry.delete(contextId);
1443
+ }
1444
+ evictAvailabilityProjectionPool() {
1445
+ while (this.availabilityProjectionPool.size > MAX_AVAILABILITY_PROJECTION_POOL) {
1446
+ const oldestEntry = this.availabilityProjectionPool.entries().next().value;
1447
+ if (!oldestEntry) break;
1448
+ const [projectionKey, projectionEntry] = oldestEntry;
1449
+ this.availabilityProjectionPool.delete(projectionKey);
1450
+ const context = this.availabilityContextRegistry.get(projectionEntry.contextId);
1451
+ if (context?.activeProjectionKey === projectionKey) {
1452
+ context.activeProjectionKey = null;
1453
+ context.refreshNonce += 1;
1454
+ }
1455
+ }
1456
+ }
1457
+ evictAvailabilityContextRegistry() {
1458
+ while (this.availabilityContextRegistry.size > MAX_AVAILABILITY_CONTEXT_REGISTRY) {
1459
+ const oldestContext = this.availabilityContextRegistry.entries().next().value;
1460
+ if (!oldestContext) break;
1461
+ this.removeAvailabilityContext(oldestContext[0]);
1462
+ }
1463
+ }
1464
+ invalidateAvailabilityContextsAfterCommit() {
1465
+ this.availabilityLifecycleGeneration += 1;
1466
+ const nextOrderRevision = this.getCurrentOrderRevision();
1467
+ this.availabilityProjectionPool.clear();
1468
+ this.availabilityPrepareInFlight.clear();
1469
+ this.availabilityProjectionRefreshInFlight.clear();
1470
+ this.availabilityContextRegistry.forEach(context => {
1471
+ context.activeVersion += 1;
1472
+ context.activeProjectionKey = null;
1473
+ context.lastKnownOrderRevision = nextOrderRevision;
1474
+ context.refreshNonce += 1;
1475
+ context.lifecycleGeneration = this.availabilityLifecycleGeneration;
1476
+ });
1477
+ }
1478
+ async resetAvailabilityProjectionState(action) {
1479
+ this.availabilityLifecycleGeneration += 1;
1480
+ this.availabilityContextRegistry.clear();
1481
+ this.availabilityProjectionPool.clear();
1482
+ this.availabilityPrepareInFlight.clear();
1483
+ this.availabilityProjectionRefreshInFlight.clear();
1484
+ this.availabilityCommitLocks.clear();
1485
+ this.clearAvailabilityRemoteResourceState();
1486
+ await this.emitAvailabilityDiagnostic({
1487
+ action,
1488
+ contextId: null,
1489
+ contextVersion: null,
1490
+ reason: action,
1491
+ projection: null
1492
+ });
1493
+ }
1494
+ isSessionStoragePersistEnabled() {
1495
+ return Boolean(this.cacheId && this.otherParams?.enableSessionStoragePersist === true);
1496
+ }
1497
+ shouldUseSessionStorageForSubModule(moduleName) {
1498
+ return this.isSessionStoragePersistEnabled() && moduleName === 'order';
1499
+ }
1500
+
1501
+ /**
1502
+ * 读取与当前 cacheId 同分桶的 Solution 会话状态。
1503
+ * 该状态独立于 otherParams,适合页面皮肤保存短生命周期 UI 状态。
1504
+ */
1505
+ getSessionStateValue(key) {
1506
+ if (!key || !this.isSessionStoragePersistEnabled()) return undefined;
1507
+ const cacheData = this.readSessionCacheData();
1508
+ const solutionCache = cacheData?.[this.name];
1509
+ if (!isRecord(solutionCache) || !isRecord(solutionCache.sessionState)) {
1510
+ return undefined;
1511
+ }
1512
+ return solutionCache.sessionState[key];
1513
+ }
1514
+
1515
+ /**
1516
+ * 写入与当前 cacheId 同分桶的 Solution 会话状态。
1517
+ * value 为 undefined 时删除该 key;存储失败不阻断业务流程。
1518
+ */
1519
+ setSessionStateValue(key, value) {
1520
+ if (!key || !this.isSessionStoragePersistEnabled()) return;
1521
+ const cacheData = this.readSessionCacheData();
1522
+ const solutionCache = cacheData?.[this.name];
1523
+ const currentSessionState = isRecord(solutionCache) && isRecord(solutionCache.sessionState) ? solutionCache.sessionState : {};
1524
+ const nextSessionState = {
1525
+ ...currentSessionState
1526
+ };
1527
+ if (value === undefined) {
1528
+ delete nextSessionState[key];
1529
+ } else {
1530
+ nextSessionState[key] = value;
1531
+ }
1532
+ this.checkSaveCache({
1533
+ cacheId: this.cacheId,
1534
+ fatherModule: this.name,
1535
+ store: {
1536
+ sessionState: nextSessionState
1537
+ },
1538
+ cacheKey: ['sessionState']
503
1539
  });
504
1540
  }
505
1541
 
@@ -525,12 +1561,9 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
525
1561
  return 1;
526
1562
  }
527
1563
  getRegisteredModuleNames() {
528
- return [...super.getRegisteredModuleNames(), 'resourcePlanner', 'openData'];
1564
+ return [...super.getRegisteredModuleNames(), 'openData'];
529
1565
  }
530
1566
  createSubModule(moduleName) {
531
- if (moduleName === 'resourcePlanner') {
532
- return new _modules.ResourcePlannerModule(`${this.name}_resourcePlanner`);
533
- }
534
1567
  if (moduleName === 'openData') {
535
1568
  return new _modules.OpenDataModule(`${this.name}_openData`);
536
1569
  }
@@ -582,8 +1615,7 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
582
1615
  return true;
583
1616
  }
584
1617
  isAuthenticatedCustomerUserPlatform() {
585
- const platform = String(this.otherParams?.platform || '').toLowerCase();
586
- return platform === 'pc' || platform === 'h5';
1618
+ return (0, _platform.isCustomerUserPlatform)(this.otherParams?.platform);
587
1619
  }
588
1620
  resolveDiscountOrderIdentity(tempOrder) {
589
1621
  return tempOrder?.order_id || this.store.order?.getOrderIdentity?.()?.orderId || null;
@@ -675,6 +1707,113 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
675
1707
  channel
676
1708
  });
677
1709
  }
1710
+ resetAllergyItemRuleState() {
1711
+ this.allergyItemRuleConfigs = [];
1712
+ this.allergyItemRuleConfigsPromise = null;
1713
+ this.allergyItemRuleEvaluator.setStrategyConfigs([]);
1714
+ }
1715
+ async fetchAllergyItemRuleConfigs(strategyModelIds) {
1716
+ if (!strategyModelIds.length) return [];
1717
+ try {
1718
+ const response = await this.request.get('/promotion', {
1719
+ skip: 1,
1720
+ num: 99,
1721
+ status: 'active',
1722
+ ids: strategyModelIds
1723
+ });
1724
+ if (response?.code !== 200 || !Array.isArray(response?.data?.list)) {
1725
+ throw new Error('[UnifiedBookingSales] 商品过敏策略加载失败');
1726
+ }
1727
+ const list = response.data.list;
1728
+ return normalizeItemRuleStrategyConfigs(list.map(item => item?.metadata));
1729
+ } catch (error) {
1730
+ // this.logMethodError('fetchAllergyItemRuleConfigs', error, {
1731
+ // strategyModelIds,
1732
+ // });
1733
+ throw error;
1734
+ }
1735
+ }
1736
+ async ensureAllergyItemRuleConfigsLoaded() {
1737
+ if (this.allergyItemRuleConfigs.length > 0) {
1738
+ return this.allergyItemRuleConfigs;
1739
+ }
1740
+ if (this.allergyItemRuleConfigsPromise) {
1741
+ return this.allergyItemRuleConfigsPromise;
1742
+ }
1743
+ this.allergyItemRuleConfigsPromise = (async () => {
1744
+ const staticConfigs = normalizeItemRuleStrategyConfigs(this.otherParams?.itemRule?.strategyConfigs);
1745
+ if (staticConfigs.length > 0) {
1746
+ this.allergyItemRuleConfigs = staticConfigs;
1747
+ this.allergyItemRuleEvaluator.setStrategyConfigs(staticConfigs);
1748
+ return staticConfigs;
1749
+ }
1750
+ const openData = await this.getOpenData();
1751
+ const enabled = toConfiguredBoolean(openData?.['sale.enable_item_rules']);
1752
+ const strategyModelIds = normalizePositiveIntegerList(openData?.['sale.enabled_item_rules']);
1753
+ if (!enabled || !strategyModelIds.length) {
1754
+ this.allergyItemRuleEvaluator.setStrategyConfigs([]);
1755
+ return [];
1756
+ }
1757
+ const loaded = await this.fetchAllergyItemRuleConfigs(strategyModelIds);
1758
+ this.allergyItemRuleConfigs = loaded;
1759
+ this.allergyItemRuleEvaluator.setStrategyConfigs(loaded);
1760
+ return loaded;
1761
+ })();
1762
+ return this.allergyItemRuleConfigsPromise;
1763
+ }
1764
+
1765
+ /**
1766
+ * 评估当前渠道与商品是否命中 ALLERGY_CHECK。
1767
+ * 只返回优先级最高 Action 中当前商品 Target 的 dataSource,不跨商品或 Action 合并。
1768
+ */
1769
+ async resolveProductAllergyRequirement(params) {
1770
+ const productId = normalizePositiveInteger(params.productId);
1771
+ if (!productId) return null;
1772
+ const strategyConfigs = await this.ensureAllergyItemRuleConfigsLoaded();
1773
+ if (!strategyConfigs.length) return null;
1774
+ const productVariantId = normalizePositiveInteger(params.productVariantId);
1775
+ const runtimeConfig = this.otherParams?.itemRule || {};
1776
+ const runtimePax = runtimeConfig.pax || {};
1777
+ const cartItems = (this.getOrderProducts?.() || []).map(item => ({
1778
+ product_id: Number(item?.product_id ?? item?.id),
1779
+ product_variant_id: Number(item?.product_variant_id ?? 0) || undefined,
1780
+ quantity: Number(item?.num ?? item?.quantity ?? 0)
1781
+ })).filter(item => Number.isFinite(item.product_id) && item.product_id > 0 && Number.isFinite(item.quantity) && item.quantity > 0);
1782
+ return this.allergyItemRuleEvaluator.getAllergyRequirement({
1783
+ pax: {
1784
+ total: Number(runtimePax.total ?? 0) || 0,
1785
+ adult: Number(runtimePax.adult ?? 0) || 0,
1786
+ child: Number(runtimePax.child ?? 0) || 0
1787
+ },
1788
+ cartItems,
1789
+ serviceType: String(runtimeConfig.serviceType ?? this.otherParams?.serviceType ?? ''),
1790
+ channel: String(params.channel ?? this.otherParams?.channel ?? this.otherParams?.platform ?? ''),
1791
+ currentProduct: {
1792
+ product_id: productId,
1793
+ ...(productVariantId ? {
1794
+ product_variant_id: productVariantId
1795
+ } : {})
1796
+ },
1797
+ submissionIndex: Number(runtimeConfig.submissionIndex ?? 0) || 0,
1798
+ strategyConfigs,
1799
+ custom: runtimeConfig.custom
1800
+ });
1801
+ }
1802
+
1803
+ /**
1804
+ * Load a resource policy without depending on the legacy appointmentBooking solution.
1805
+ */
1806
+ async getProtocol(policyId) {
1807
+ const normalizedPolicyId = String(policyId ?? '').trim();
1808
+ if (!normalizedPolicyId) {
1809
+ throw new Error('[UnifiedBookingSales] getProtocol 需要 policyId');
1810
+ }
1811
+ let policyUrl = 'shop-policy';
1812
+ if (this.otherParams.platform === 'kiosk' || this.otherParams.platform === 'pos' || this.otherParams.platform === 'shop') {
1813
+ policyUrl = 'policy';
1814
+ }
1815
+ return this.request.get(`/${policyUrl}/${encodeURIComponent(normalizedPolicyId)}`);
1816
+ }
678
1817
 
679
1818
  /**
680
1819
  * Loads the product catalog used by both UI and Planner. Schedule data is requested with each
@@ -696,6 +1835,10 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
696
1835
  await this.core.effects.emit(`${this.name}:onProductsLoaded`, result);
697
1836
  return result;
698
1837
  }
1838
+ async queryProducts(params = {}) {
1839
+ if (!this.store.products) throw new Error('[UnifiedBookingSales] products 模块未初始化');
1840
+ return this.store.products.queryProducts(params);
1841
+ }
699
1842
 
700
1843
  /** Date-first entry point. It delegates protocol details and cache ownership to ScheduleModule. */
701
1844
  async loadScheduleAvailableDate(params) {
@@ -863,23 +2006,17 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
863
2006
  preferredDiscountFound: preferredDiscountId === undefined || preferredDiscountId === null ? undefined : Boolean(preferredDiscount)
864
2007
  });
865
2008
  }
866
-
867
- /** ResourcePlanner is a required child module; fail early instead of producing partial availability. */
868
- getPlanner() {
869
- if (!this.store.resourcePlanner) {
870
- throw new Error('[UnifiedBookingSales] resourcePlanner 模块未初始化');
871
- }
872
- return this.store.resourcePlanner;
873
- }
874
2009
  buildUnifiedOrderProduct(sourceProduct, ownershipKey, product) {
2010
+ const normalizedProductRaw = product?.raw || {};
2011
+ const quotationShelfId = sourceProduct._extend?.quotation_shelf_id ?? sourceProduct.quotation_shelf_id ?? sourceProduct.metadata?.quotation_shelf_id;
875
2012
  const orderProduct = this.transformBaseProductToOrderProduct({
876
2013
  payload: {
877
2014
  ...sourceProduct,
878
2015
  id: sourceProduct.id ?? product?.id,
879
2016
  product_id: sourceProduct.product_id ?? sourceProduct.id ?? product?.id,
880
2017
  quantity: sourceProduct.quantity ?? sourceProduct.num ?? product?.quantity ?? 1,
881
- price: sourceProduct.price ?? product?.price,
882
- selling_price: sourceProduct.selling_price ?? sourceProduct.price ?? product?.price,
2018
+ price: sourceProduct.price ?? normalizedProductRaw.price,
2019
+ selling_price: sourceProduct.selling_price ?? sourceProduct.price ?? normalizedProductRaw.selling_price ?? normalizedProductRaw.price,
883
2020
  metadata: {
884
2021
  ...(sourceProduct.metadata || {}),
885
2022
  [ownershipKey]: true
@@ -888,11 +2025,14 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
888
2025
  sourceProduct
889
2026
  });
890
2027
  if (orderProduct && typeof orderProduct === 'object') {
891
- // BaseSales may rebuild metadata from its price source. Stamp ownership after that transform
892
- // so each UnifiedBookingSales entry point can replace only its own temporary lines.
2028
+ // BaseSales may rebuild metadata from its price source. Stamp ownership and the selected
2029
+ // quotation shelf after that transform so neither booking adds nor edits lose quote identity.
893
2030
  orderProduct.metadata = {
894
2031
  ...(orderProduct.metadata || {}),
895
- [ownershipKey]: true
2032
+ [ownershipKey]: true,
2033
+ ...(quotationShelfId !== undefined ? {
2034
+ quotation_shelf_id: quotationShelfId
2035
+ } : {})
896
2036
  };
897
2037
  }
898
2038
  return orderProduct;
@@ -902,7 +2042,7 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
902
2042
  async removeExistingStagedLines(ownershipKey) {
903
2043
  const existingLines = (this.getTempOrder()?.products || []).filter(product => product?.metadata?.[ownershipKey] === true);
904
2044
  if (existingLines.length > 0) {
905
- await this.removeProductsFromOrder(existingLines.map(buildPlannerLineIdentity));
2045
+ await this.removeProductsFromOrder(existingLines.map(buildAvailabilityLineIdentity));
906
2046
  }
907
2047
  }
908
2048
  async stageProducts(params, ownershipKey) {
@@ -930,14 +2070,6 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
930
2070
  return this.getTempOrder();
931
2071
  }
932
2072
 
933
- /**
934
- * Adds the current product choice to an otherwise clean planner cart without creating bookings.
935
- * This separates "what the customer wants" from the later time/resource confirmation step.
936
- */
937
- async stagePlannerProducts(params) {
938
- return this.stageProducts(params, 'unified_booking_sales');
939
- }
940
-
941
2073
  /**
942
2074
  * Adds ordinary retail products without asking ResourcePlanner for booking facts.
943
2075
  * Its staged lines have separate ownership so planner and retail skins can coexist safely.
@@ -980,59 +2112,194 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
980
2112
  }
981
2113
  return this.addProductToOrder(orderProduct);
982
2114
  }
983
-
984
- /** Normalizes caller-provided products or loads the catalog before any resource request is made. */
985
- async loadPlannerProducts(params) {
986
- if (params.context?.products?.length) return params.context.products;
987
- if (params.rawProducts?.length) return params.rawProducts.map(normalizeProductForPlanner);
988
- const productIds = params.productIds || params.productLoadParams?.product_ids || [];
989
- if (productIds.length > 0 || params.productLoadParams) {
990
- await this.loadProducts({
991
- ...(params.date ? {
992
- schedule_date: params.date
993
- } : {}),
994
- ...(params.productLoadParams || {}),
995
- product_ids: productIds.length ? productIds.map(Number).filter(Number.isFinite) : params.productLoadParams?.product_ids
2115
+ normalizeAvailabilityDateRange(params, timezone) {
2116
+ const today = (0, _localClock.formatLocalDate)((0, _localClock.localDateToScalar)(new Date()));
2117
+ const startDate = params.dateRange?.startDate || today;
2118
+ const endDate = params.dateRange?.endDate || (0, _localClock.formatLocalDate)((0, _localClock.addLocalCalendarDays)((0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(startDate)), 31));
2119
+ let startScalar;
2120
+ let endScalar;
2121
+ try {
2122
+ startScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(startDate));
2123
+ endScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(endDate));
2124
+ } catch (error) {
2125
+ throw new _ResourcePlanner.AvailabilityError('INVALID_DATE_RANGE', '日期范围必须是有效的 YYYY-MM-DD 且结束日期不早于开始日期', {
2126
+ dateRange: {
2127
+ startDate,
2128
+ endDate
2129
+ },
2130
+ timezone,
2131
+ cause: error instanceof Error ? error.message : String(error)
2132
+ });
2133
+ }
2134
+ if (endScalar < startScalar) {
2135
+ throw new _ResourcePlanner.AvailabilityError('INVALID_DATE_RANGE', '日期范围必须是有效的 YYYY-MM-DD 且结束日期不早于开始日期', {
2136
+ dateRange: {
2137
+ startDate,
2138
+ endDate
2139
+ },
2140
+ timezone
996
2141
  });
997
2142
  }
998
- return (this.getProductCatalog?.().products || []).map(normalizeProductForPlanner);
2143
+ const inclusiveDays = Math.trunc((endScalar - startScalar) / (24 * 60 * 60 * 1000)) + 1;
2144
+ if (inclusiveDays > MAX_AVAILABILITY_RANGE_DAYS) {
2145
+ throw new _ResourcePlanner.AvailabilityError('RANGE_TOO_LARGE', '日期范围最多包含 90 个自然日', {
2146
+ dateRange: {
2147
+ startDate,
2148
+ endDate
2149
+ },
2150
+ inclusiveDays
2151
+ });
2152
+ }
2153
+ return {
2154
+ startDate,
2155
+ endDate
2156
+ };
999
2157
  }
1000
-
1001
- /** Loads the reusable store-level schedule catalog on demand. */
1002
- async ensurePlannerSchedulesLoaded() {
1003
- if (!this.store.schedule) throw new Error('[UnifiedBookingSales] schedule 模块未初始化');
1004
- const scheduleList = this.store.schedule.getScheduleList?.() || [];
1005
- if (!this.store.schedule.isScheduleListLoaded?.() || scheduleList.length === 0) {
1006
- await this.store.schedule.loadAllSchedule();
2158
+ assertAvailabilityPrepareParams(params) {
2159
+ assertAvailabilityCatalogProductIds(params.productIds, 'prepare.productIds');
2160
+ const hasProductAnchor = Array.isArray(params.productIds) && params.productIds.length > 0;
2161
+ const hasResourceAnchor = Array.isArray(params.resourceIds) && params.resourceIds.length > 0;
2162
+ const hasDateAnchor = Boolean(params.dateRange?.startDate || params.dateRange?.endDate);
2163
+ if (!hasProductAnchor && !hasResourceAnchor && !hasDateAnchor) {
2164
+ throw new _ResourcePlanner.AvailabilityError('MISSING_ANCHOR', 'prepare 至少需要 productIds、resourceIds 或 dateRange 之一', {
2165
+ params
2166
+ });
2167
+ }
2168
+ if (!hasProductAnchor && !params.catalogScope) {
2169
+ throw new _ResourcePlanner.AvailabilityError('MISSING_CATALOG_SCOPE', '缺少 productIds 时必须提供 catalogScope', {
2170
+ params
2171
+ });
2172
+ }
2173
+ if (params.slotStepMinutes !== undefined) {
2174
+ const step = Number(params.slotStepMinutes);
2175
+ if (!Number.isInteger(step) || step <= 0) {
2176
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'slotStepMinutes 必须是正整数', {
2177
+ slotStepMinutes: params.slotStepMinutes
2178
+ });
2179
+ }
1007
2180
  }
1008
- return this.store.schedule.getScheduleList?.() || [];
2181
+ }
2182
+ captureAvailabilityScheduleCatalog() {
2183
+ if (!this.store.schedule) {
2184
+ throw new Error('[UnifiedBookingSales] schedule 模块未初始化');
2185
+ }
2186
+ this.availabilityScheduleCatalog = (0, _lodashEs.cloneDeep)(this.store.schedule.getScheduleList?.() || []);
1009
2187
  }
1010
2188
 
1011
- /**
1012
- * v2 resources reference schedule ids rather than expanded work times. Refresh once when the
1013
- * cached schedule catalog cannot materialize all referenced ids.
1014
- */
1015
- async ensurePlannerSchedulesForResources(rawResources) {
1016
- let scheduleList = await this.ensurePlannerSchedulesLoaded();
1017
- const scheduleIds = collectRawScheduleIds(rawResources);
1018
- if (!scheduleListHasIds(scheduleList, scheduleIds)) {
1019
- await this.store.schedule.loadAllSchedule({
1020
- useCache: false
2189
+ /** Returns the solution-lifetime schedule snapshot captured after initialization. */
2190
+ getAvailabilityScheduleCatalog() {
2191
+ if (this.availabilityScheduleCatalog === null) {
2192
+ throw new Error('[UnifiedBookingSales] schedule 快照未初始化');
2193
+ }
2194
+ return this.availabilityScheduleCatalog;
2195
+ }
2196
+ buildAvailabilityOperatingHoursSnapshot(params) {
2197
+ const descriptor = params.prepare._availabilityOperatingHours || {
2198
+ source: params.prepare.businessHours ? 'prepare_business_hours' : 'default_business_hours',
2199
+ scheduleIds: []
2200
+ };
2201
+ if (descriptor.source !== 'open_data_schedule') {
2202
+ const windows = dateRangeDays(params.dateRange).flatMap(date => {
2203
+ const range = normalizeAvailabilityLocalDateTimeRange({
2204
+ startAt: `${date}T${params.businessHours.startTime}:00`,
2205
+ endAt: `${date}T${params.businessHours.endTime}:00`,
2206
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2207
+ rollOverEnd: true
2208
+ });
2209
+ return range ? [{
2210
+ startAt: range.startAt,
2211
+ endAt: range.endAt,
2212
+ source: descriptor.source
2213
+ }] : [];
2214
+ });
2215
+ return {
2216
+ source: descriptor.source,
2217
+ scheduleIds: [],
2218
+ missingScheduleIds: [],
2219
+ windows
2220
+ };
2221
+ }
2222
+ const scheduleMap = new Map(params.scheduleList.map(schedule => [String(schedule.id), schedule]));
2223
+ const schedules = descriptor.scheduleIds.map(scheduleId => scheduleMap.get(String(scheduleId))).filter(schedule => Boolean(schedule));
2224
+ const matchedScheduleIds = new Set(schedules.map(schedule => String(schedule.id)));
2225
+ const missingScheduleIds = descriptor.scheduleIds.filter(scheduleId => !matchedScheduleIds.has(String(scheduleId)));
2226
+ const windows = dateRangeDays(params.dateRange).flatMap(date => (0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, schedules).flatMap(slot => {
2227
+ const range = normalizeAvailabilityLocalDateTimeRange({
2228
+ startAt: slot.start_at,
2229
+ endAt: slot.end_at,
2230
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2231
+ rollOverEnd: true
2232
+ });
2233
+ return range ? [{
2234
+ startAt: range.startAt,
2235
+ endAt: range.endAt,
2236
+ source: `operating_schedule:${descriptor.scheduleIds.map(String).join(',')}`
2237
+ }] : [];
2238
+ }));
2239
+ return {
2240
+ source: descriptor.source,
2241
+ scheduleIds: [...descriptor.scheduleIds],
2242
+ missingScheduleIds,
2243
+ windows
2244
+ };
2245
+ }
2246
+ async loadAvailabilityProducts(params) {
2247
+ const {
2248
+ prepare,
2249
+ dateRange
2250
+ } = params;
2251
+ const explicitProductIds = prepare.productIds || [];
2252
+ let rawProducts = [];
2253
+ if (Array.isArray(prepare.rawProducts)) {
2254
+ rawProducts = prepare.rawProducts;
2255
+ this.unifiedProductCatalog = [...rawProducts];
2256
+ } else if (explicitProductIds.length > 0) {
2257
+ rawProducts = await this.loadProducts({
2258
+ product_ids: explicitProductIds,
2259
+ schedule_date: dateRange.startDate
2260
+ });
2261
+ } else if (prepare.catalogScope?.type === 'schedule') {
2262
+ await this.loadScheduleAvailableDate({
2263
+ startDate: dateRange.startDate,
2264
+ endDate: dateRange.endDate,
2265
+ custom_page_id: Number(prepare.catalogScope.customPageId) || undefined,
2266
+ channel: prepare.catalogScope.channel
2267
+ });
2268
+ const dateSet = new Set(dateRangeDays(dateRange));
2269
+ const scheduleProductIds = (this.store.schedule.getAvailabilityScheduleDateList?.() || []).filter(item => dateSet.has(item.date)).flatMap(item => item.product_ids || []);
2270
+ const productIds = uniqueAvailabilityIds([...scheduleProductIds, ...(this.store.schedule.getOtherProductsIds?.() || [])]);
2271
+ if (productIds.length > 0) {
2272
+ assertAvailabilityCatalogProductIds(productIds, 'prepare.catalogScope.scheduleProductIds');
2273
+ rawProducts = await this.loadProducts({
2274
+ product_ids: productIds,
2275
+ schedule_date: dateRange.startDate
2276
+ });
2277
+ } else {
2278
+ this.unifiedProductCatalog = [];
2279
+ }
2280
+ } else if (prepare.catalogScope?.type === 'productList') {
2281
+ rawProducts = await this.loadProducts({
2282
+ ...prepare.catalogScope.params,
2283
+ schedule_date: prepare.catalogScope.params.schedule_date || dateRange.startDate
1021
2284
  });
1022
- scheduleList = this.store.schedule.getScheduleList?.() || [];
1023
2285
  }
1024
- return scheduleList;
2286
+ const normalized = rawProducts.map(product => normalizeProductForAvailability(product, params.scheduleList, this.getAvailabilityRuntimeOffsetMinutes()));
2287
+ if (!explicitProductIds.length && prepare.resourceIds?.length) {
2288
+ return normalized.filter(product => product.requirementGroups?.some(group => group.resourceIds?.some(resourceId => prepare.resourceIds.some(id => sameAvailabilityId(id, resourceId)))));
2289
+ }
2290
+ const productIdSet = explicitProductIds.length ? new Set(explicitProductIds.map(String)) : null;
2291
+ return productIdSet ? normalized.filter(product => productIdSet.has(String(product.id))) : normalized;
1025
2292
  }
1026
2293
 
1027
2294
  /**
1028
2295
  * Fetches resource metadata plus flat occupied events. Work windows are intentionally derived
1029
- * locally in buildPlannerResourcesFromV2 from /schedule, rather than requested from the legacy
2296
+ * locally in buildAvailabilityResourcesFromV2 from /schedule, rather than requested from the legacy
1030
2297
  * resource dates endpoint.
1031
2298
  */
1032
- async fetchPlannerResourcesV2(params) {
2299
+ async fetchAvailabilityResourcesV2(params) {
1033
2300
  const response = await this.request.get('/schedule/resource/list/v2', {
1034
- start_date: params.dateRange.start,
1035
- end_date: params.dateRange.end,
2301
+ start_date: params.dateRange.startDate,
2302
+ end_date: params.dateRange.endDate,
1036
2303
  resource_ids: params.resourceIds,
1037
2304
  front_end_cache_id: false
1038
2305
  }, {
@@ -1043,180 +2310,1081 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
1043
2310
  if (Array.isArray(data?.list)) return data.list;
1044
2311
  return [];
1045
2312
  }
1046
-
1047
- /**
1048
- * Resolves the Planner context in precedence order: explicit normalized context, test raw data,
1049
- * then the v2 resource API inferred from selected product requirements and any URL lock.
1050
- */
1051
- async loadPlannerResources(params, products = []) {
1052
- if (params.context?.resources?.length) {
1053
- return {
1054
- resources: params.context.resources,
1055
- externalEvents: params.context.externalEvents || []
1056
- };
2313
+ async fetchAndPublishAvailabilityResourcesV2(params) {
2314
+ const normalizedResourceIds = this.normalizeAvailabilityIdList(params.resourceIds) || [];
2315
+ if (normalizedResourceIds.length === 0) return [];
2316
+ const scopeKey = this.buildAvailabilityRemoteResourceScopeKey(params.timezone);
2317
+ const cacheParams = {
2318
+ scopeKey,
2319
+ resourceIds: normalizedResourceIds,
2320
+ dateRange: params.dateRange
2321
+ };
2322
+ const cachedRawResources = this.availabilityRemoteOccupancyCache.read(cacheParams);
2323
+ if (cachedRawResources) {
2324
+ if (params.useCacheOnHit) {
2325
+ void this.refreshAvailabilityRemoteResourceQuery({
2326
+ timezone: params.timezone,
2327
+ dateRange: params.dateRange,
2328
+ resourceIds: normalizedResourceIds
2329
+ }).catch(error => {
2330
+ console.warn('[UnifiedBookingSales] Remote occupancy background refresh failed', error);
2331
+ });
2332
+ }
2333
+ return cachedRawResources;
2334
+ }
2335
+ return await this.refreshAvailabilityRemoteResourceQuery({
2336
+ timezone: params.timezone,
2337
+ dateRange: params.dateRange,
2338
+ resourceIds: normalizedResourceIds
2339
+ });
2340
+ }
2341
+ async refreshAvailabilityRemoteResourceQuery(params) {
2342
+ const normalizedResourceIds = this.normalizeAvailabilityIdList(params.resourceIds) || [];
2343
+ if (normalizedResourceIds.length === 0) return [];
2344
+ const queryKey = this.buildAvailabilityRemoteResourceQueryKey({
2345
+ timezone: params.timezone,
2346
+ dateRange: params.dateRange,
2347
+ resourceIds: normalizedResourceIds
2348
+ });
2349
+ const epoch = this.availabilityRemoteResourceEpoch;
2350
+ const existing = this.availabilityRemoteResourceQueryInFlight.get(queryKey);
2351
+ if (existing?.epoch === epoch) {
2352
+ return await existing.promise;
2353
+ }
2354
+ this.availabilityRemoteResourceQuerySequence += 1;
2355
+ const writeVersion = this.availabilityRemoteResourceQuerySequence;
2356
+ const promise = this.fetchAvailabilityResourcesV2({
2357
+ dateRange: params.dateRange,
2358
+ resourceIds: normalizedResourceIds
2359
+ });
2360
+ this.availabilityRemoteResourceQueryInFlight.set(queryKey, {
2361
+ epoch,
2362
+ promise
2363
+ });
2364
+ try {
2365
+ const rawResources = await promise;
2366
+ if (epoch === this.availabilityRemoteResourceEpoch) {
2367
+ this.availabilityRemoteOccupancyCache.publish({
2368
+ scopeKey: this.buildAvailabilityRemoteResourceScopeKey(params.timezone),
2369
+ timezoneName: params.timezone,
2370
+ resourceIds: normalizedResourceIds,
2371
+ dateRange: params.dateRange,
2372
+ rawResources,
2373
+ version: writeVersion
2374
+ });
2375
+ }
2376
+ return rawResources;
2377
+ } finally {
2378
+ const current = this.availabilityRemoteResourceQueryInFlight.get(queryKey);
2379
+ if (current?.promise === promise) {
2380
+ this.availabilityRemoteResourceQueryInFlight.delete(queryKey);
2381
+ }
1057
2382
  }
1058
- if (params.rawResources?.length) {
2383
+ }
2384
+ async loadAvailabilityResources(params) {
2385
+ const resourceIds = collectAvailabilityResourceIds(params.products, params.resourceIds || []);
2386
+ if (resourceIds.length === 0) {
1059
2387
  return {
1060
- resources: params.rawResources.map(normalizeResource).filter(resource => resource !== null),
1061
- externalEvents: params.context?.externalEvents || []
2388
+ resources: [],
2389
+ resourceOccupancies: []
1062
2390
  };
1063
2391
  }
1064
- const resourceIds = collectPlannerResourceIds(products, params.resourceIds || []);
1065
- if (!params.useResourceDateApi || !params.dateRange || resourceIds.length === 0) {
1066
- return {
2392
+ if (Array.isArray(params.rawResources)) {
2393
+ return buildAvailabilityResourcesFromV2({
2394
+ rawResources: params.rawResources,
2395
+ scheduleList: params.scheduleList,
2396
+ dateRange: params.dateRange,
2397
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
2398
+ });
2399
+ }
2400
+ if (params.fetchPolicy === 'cache_only') {
2401
+ const cachedRawResources = this.availabilityRemoteOccupancyCache.read({
2402
+ scopeKey: this.buildAvailabilityRemoteResourceScopeKey(params.timezone),
2403
+ resourceIds,
2404
+ dateRange: params.dateRange
2405
+ });
2406
+ if (cachedRawResources) {
2407
+ return buildAvailabilityResourcesFromV2({
2408
+ rawResources: cachedRawResources,
2409
+ scheduleList: params.scheduleList,
2410
+ dateRange: params.dateRange,
2411
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
2412
+ });
2413
+ }
2414
+ return params.fallbackResources ? (0, _lodashEs.cloneDeep)(params.fallbackResources) : {
1067
2415
  resources: [],
1068
- externalEvents: params.context?.externalEvents || []
2416
+ resourceOccupancies: []
1069
2417
  };
1070
2418
  }
1071
- const rawResources = await this.fetchPlannerResourcesV2({
2419
+ const rawResources = await this.fetchAndPublishAvailabilityResourcesV2({
2420
+ timezone: params.timezone,
1072
2421
  dateRange: params.dateRange,
1073
- resourceIds
2422
+ resourceIds,
2423
+ useCacheOnHit: true
1074
2424
  });
1075
- const scheduleList = await this.ensurePlannerSchedulesForResources(rawResources);
1076
- const materialized = buildPlannerResourcesFromV2({
2425
+ return buildAvailabilityResourcesFromV2({
1077
2426
  rawResources,
1078
- scheduleList,
1079
- dateRange: params.dateRange
2427
+ scheduleList: params.scheduleList,
2428
+ dateRange: params.dateRange,
2429
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
2430
+ });
2431
+ }
2432
+ resolveEditingOccupancyExclusions(tempOrder, productLineUids = []) {
2433
+ const productUids = new Set();
2434
+ const bookingUids = new Set();
2435
+ const scheduleEventIds = new Set();
2436
+ if (!productLineUids.length) return {
2437
+ productUids,
2438
+ bookingUids,
2439
+ scheduleEventIds
2440
+ };
2441
+ const products = Array.isArray(tempOrder?.products) ? tempOrder.products : [];
2442
+ const bookings = Array.isArray(tempOrder?.bookings) ? tempOrder.bookings : [];
2443
+ productLineUids.forEach(requestedUid => {
2444
+ const product = products.find(line => getProductLineUid(line) === String(requestedUid));
2445
+ if (!product) {
2446
+ throw new _ResourcePlanner.AvailabilityError('SELF_EXCLUSION_RESOLVE_FAILED', '无法解析编辑商品行,不能安全排除自身占用', {
2447
+ productLineUid: requestedUid
2448
+ });
2449
+ }
2450
+ const productUid = getProductLineUid(product);
2451
+ const linkedBookingUid = String(product.booking_uid ?? product.metadata?.booking_uid ?? '');
2452
+ const linkedBookings = bookings.filter(booking => linkedBookingUid && getBookingUid(booking) === linkedBookingUid || productUid && String(booking.product_uid ?? '') === productUid);
2453
+ if (!productUid || !linkedBookings.length) {
2454
+ throw new _ResourcePlanner.AvailabilityError('SELF_EXCLUSION_RESOLVE_FAILED', '编辑商品行缺少可验证的 booking 关联', {
2455
+ productLineUid: requestedUid,
2456
+ linkedBookingUid
2457
+ });
2458
+ }
2459
+ productUids.add(productUid);
2460
+ linkedBookings.forEach(booking => {
2461
+ const bookingUid = getBookingUid(booking);
2462
+ if (bookingUid) bookingUids.add(bookingUid);
2463
+ if (booking.schedule_event_id != null) {
2464
+ scheduleEventIds.add(String(booking.schedule_event_id));
2465
+ }
2466
+ });
1080
2467
  });
1081
2468
  return {
1082
- resources: materialized.resources,
1083
- externalEvents: [...(params.context?.externalEvents || []), ...materialized.externalEvents]
2469
+ productUids,
2470
+ bookingUids,
2471
+ scheduleEventIds
1084
2472
  };
1085
2473
  }
1086
-
1087
- /**
1088
- * Boundary between IO and pure planning. Once this returns, all later availability/slot calls
1089
- * operate on the ResourcePlanner snapshot and do not issue resource requests themselves.
1090
- */
1091
- async loadPlannerContext(params = {}) {
1092
- const products = await this.loadPlannerProducts(params);
1093
- const plannerResources = await this.loadPlannerResources(params, products);
1094
- const context = {
1095
- ...(params.context || {}),
2474
+ occupancyIsExcluded(occupancy, exclusions) {
2475
+ if (occupancy.scheduleEventId != null) {
2476
+ return exclusions.scheduleEventIds.has(String(occupancy.scheduleEventId));
2477
+ }
2478
+ if (occupancy.bookingUid != null) {
2479
+ return exclusions.bookingUids.has(String(occupancy.bookingUid));
2480
+ }
2481
+ if (occupancy.productUid != null) {
2482
+ return exclusions.productUids.has(String(occupancy.productUid));
2483
+ }
2484
+ return false;
2485
+ }
2486
+ collectCartResourceOccupancies(tempOrder, exclusions) {
2487
+ const bookings = Array.isArray(tempOrder?.bookings) ? tempOrder.bookings : [];
2488
+ return bookings.flatMap(booking => {
2489
+ const bookingUid = getBookingUid(booking);
2490
+ const productUid = String(booking.product_uid ?? booking.metadata?.product_uid ?? '');
2491
+ const scheduleEventId = toNumberId(booking.schedule_event_id);
2492
+ const startAt = booking.start_at || `${booking.start_date || ''} ${booking.start_time || ''}`.trim();
2493
+ const endAt = booking.end_at || `${booking.end_date || booking.start_date || ''} ${booking.end_time || ''}`.trim();
2494
+ const resources = Array.isArray(booking.resources) ? booking.resources : [];
2495
+ return resources.flatMap(resource => {
2496
+ const resourceId = resource.resource_id ?? resource.relation_id ?? resource.id;
2497
+ if (resourceId == null || !startAt || !endAt) return [];
2498
+ const range = normalizeAvailabilityLocalDateTimeRange({
2499
+ startAt,
2500
+ endAt,
2501
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2502
+ rollOverEnd: false
2503
+ });
2504
+ if (!range) return [];
2505
+ const occupancy = {
2506
+ resourceId,
2507
+ startAt: range.startAt,
2508
+ endAt: range.endAt,
2509
+ pax: Math.max(1, Number(resource.capacity ?? booking.capacity ?? 1) || 1),
2510
+ source: 'cart',
2511
+ scheduleEventId,
2512
+ bookingUid: bookingUid || undefined,
2513
+ productUid: productUid || undefined,
2514
+ raw: {
2515
+ booking,
2516
+ resource
2517
+ }
2518
+ };
2519
+ return this.occupancyIsExcluded(occupancy, exclusions) ? [] : [occupancy];
2520
+ });
2521
+ });
2522
+ }
2523
+ getCurrentOrderRevision() {
2524
+ return resolveOrderRevision(this.getTempOrder());
2525
+ }
2526
+ getAvailabilityContextEntry(contextId) {
2527
+ const context = this.availabilityContextRegistry.get(contextId);
2528
+ if (!context) {
2529
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'availability context 不存在或已失效,请重新 prepare', {
2530
+ contextId,
2531
+ lifecycleGeneration: this.availabilityLifecycleGeneration
2532
+ });
2533
+ }
2534
+ this.touchAvailabilityContext(context);
2535
+ return context;
2536
+ }
2537
+ getAvailabilityProjectionByKey(projectionKey) {
2538
+ if (!projectionKey) return null;
2539
+ return this.availabilityProjectionPool.get(projectionKey)?.projection || null;
2540
+ }
2541
+ getActiveAvailabilityProjectionEntry(context) {
2542
+ if (!context.activeProjectionKey) return null;
2543
+ const entry = this.availabilityProjectionPool.get(context.activeProjectionKey);
2544
+ if (!entry) return null;
2545
+ return this.touchAvailabilityProjection(entry);
2546
+ }
2547
+ decorateAvailabilityCandidate(contextId, contextVersion, candidate) {
2548
+ return {
2549
+ ...candidate,
2550
+ availabilityContextId: contextId,
2551
+ availabilityContextVersion: contextVersion
2552
+ };
2553
+ }
2554
+ assertAvailabilityCandidateFreshness(context, candidate) {
2555
+ if (candidate.availabilityContextId !== context.contextId) {
2556
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选与 availability context 不匹配,请重新获取候选', {
2557
+ candidateContextId: candidate.availabilityContextId,
2558
+ contextId: context.contextId
2559
+ });
2560
+ }
2561
+ const currentContextVersion = context.activeVersion;
2562
+ if (candidate.availabilityContextVersion !== currentContextVersion) {
2563
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选已过期,请重新获取最新时间段', {
2564
+ contextId: context.contextId,
2565
+ candidateContextVersion: candidate.availabilityContextVersion,
2566
+ currentContextVersion,
2567
+ snapshotId: context.lastSnapshotId
2568
+ });
2569
+ }
2570
+ }
2571
+ assertContextualQueryFreshness(context, request) {
2572
+ if (request.target === 'time') {
2573
+ request.candidates.forEach(candidate => {
2574
+ this.assertAvailabilityCandidateFreshness(context, candidate);
2575
+ });
2576
+ return;
2577
+ }
2578
+ if (request.target === 'resource') {
2579
+ request.selectedRanges.forEach(candidate => {
2580
+ this.assertAvailabilityCandidateFreshness(context, candidate);
2581
+ });
2582
+ }
2583
+ }
2584
+ storeAvailabilityProjectionEntry(params) {
2585
+ const previousContext = this.availabilityContextRegistry.get(params.contextId);
2586
+ const contextVersion = previousContext ? previousContext.activeVersion + (params.incrementVersion ? 1 : 0) : 1;
2587
+ const projectionKey = this.buildAvailabilityProjectionKey(params.contextId, contextVersion);
2588
+ const entry = {
2589
+ contextId: params.contextId,
2590
+ contextVersion,
2591
+ projectionKey,
2592
+ projection: params.projection,
2593
+ orderRevision: params.orderRevision,
2594
+ preparedAt: params.projection.meta.preparedAt,
2595
+ snapshotId: params.projection.meta.snapshotId
2596
+ };
2597
+ if (previousContext?.activeProjectionKey && previousContext.activeProjectionKey !== projectionKey) {
2598
+ this.removeAvailabilityProjection(previousContext.activeProjectionKey);
2599
+ }
2600
+ this.availabilityProjectionPool.set(projectionKey, entry);
2601
+ this.evictAvailabilityProjectionPool();
2602
+ const nextContext = {
2603
+ contextId: params.contextId,
2604
+ prepareParams: this.cloneAvailabilityPrepareParams(params.prepareParams),
2605
+ prepareKey: params.prepareKey,
2606
+ activeVersion: contextVersion,
2607
+ activeProjectionKey: projectionKey,
2608
+ createdAt: previousContext?.createdAt ?? params.projection.meta.preparedAt,
2609
+ lastAccessedAt: new Date().toISOString(),
2610
+ lastKnownOrderRevision: params.orderRevision,
2611
+ lastPreparedAt: params.projection.meta.preparedAt,
2612
+ lastSnapshotId: params.projection.meta.snapshotId,
2613
+ durationCandidateAnchorAt: previousContext?.durationCandidateAnchorAt ?? this.getAvailabilityDurationCandidateAnchorAt(params.projection),
2614
+ refreshNonce: previousContext?.refreshNonce ?? 0,
2615
+ lifecycleGeneration: this.availabilityLifecycleGeneration
2616
+ };
2617
+ this.touchAvailabilityContext(nextContext);
2618
+ this.evictAvailabilityContextRegistry();
2619
+ return this.touchAvailabilityProjection(entry);
2620
+ }
2621
+ async buildAvailabilityProjectionSnapshot(params, options = {}) {
2622
+ const normalizedPrepareParams = this.normalizeAvailabilityPrepareDescriptor(params);
2623
+ const timezoneLabel = String(normalizedPrepareParams.timezone);
2624
+ const dateRange = normalizedPrepareParams.dateRange;
2625
+ const slotStepMinutes = normalizedPrepareParams.slotStepMinutes || DEFAULT_AVAILABILITY_SLOT_STEP_MINUTES;
2626
+ const businessHours = normalizedPrepareParams.businessHours || DEFAULT_AVAILABILITY_BUSINESS_HOURS;
2627
+ const mode = normalizedPrepareParams.mode || 'appointment';
2628
+ const scheduleList = this.getAvailabilityScheduleCatalog();
2629
+ const operatingHours = this.buildAvailabilityOperatingHoursSnapshot({
2630
+ prepare: normalizedPrepareParams,
2631
+ dateRange,
2632
+ scheduleList,
2633
+ businessHours
2634
+ });
2635
+ const products = await this.loadAvailabilityProducts({
2636
+ prepare: normalizedPrepareParams,
2637
+ dateRange,
2638
+ scheduleList
2639
+ });
2640
+ const loadedResources = await this.loadAvailabilityResources({
2641
+ timezone: timezoneLabel,
2642
+ dateRange,
2643
+ resourceIds: normalizedPrepareParams.resourceIds,
1096
2644
  products,
1097
- resources: plannerResources.resources,
1098
- externalEvents: plannerResources.externalEvents
2645
+ rawResources: normalizedPrepareParams.rawResources,
2646
+ scheduleList,
2647
+ fetchPolicy: options.resourceFetchPolicy || 'default',
2648
+ fallbackResources: options.fallbackResources
2649
+ });
2650
+ const tempOrder = this.getTempOrder();
2651
+ const exclusions = this.resolveEditingOccupancyExclusions(tempOrder, normalizedPrepareParams.editingProductLineUids);
2652
+ const injectedRawOccupancies = (normalizedPrepareParams.rawOccupancies || []).flatMap(occupancy => {
2653
+ const range = normalizeAvailabilityLocalDateTimeRange({
2654
+ startAt: occupancy.startAt,
2655
+ endAt: occupancy.endAt,
2656
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2657
+ rollOverEnd: false
2658
+ });
2659
+ if (!range) return [];
2660
+ return [{
2661
+ ...occupancy,
2662
+ startAt: range.startAt,
2663
+ endAt: range.endAt
2664
+ }];
2665
+ });
2666
+ const remoteOccupancies = [...loadedResources.resourceOccupancies, ...(loadedResources.includesInjectedRawOccupancies ? [] : injectedRawOccupancies)].filter(occupancy => !this.occupancyIsExcluded(occupancy, exclusions));
2667
+ const cartOccupancies = this.collectCartResourceOccupancies(tempOrder, exclusions);
2668
+ const preparedAt = this.getAvailabilityRuntimeDateTime();
2669
+ const durationCandidateAnchorAt = options.durationCandidateAnchorAt || preparedAt;
2670
+ this.availabilityProjectionSequence += 1;
2671
+ const orderRevision = this.getCurrentOrderRevision();
2672
+ const projection = (0, _ResourcePlanner.createAvailabilityProjection)({
2673
+ products,
2674
+ resources: loadedResources.resources,
2675
+ resourceOccupancies: [...remoteOccupancies, ...cartOccupancies],
2676
+ mode,
2677
+ timezone: timezoneLabel,
2678
+ coverage: dateRange,
2679
+ slotStepMinutes,
2680
+ businessHours,
2681
+ operatingHours,
2682
+ preparedAt,
2683
+ now: preparedAt,
2684
+ orderRevision,
2685
+ snapshotId: `${this.name}:${Date.now()}:${this.availabilityProjectionSequence}`,
2686
+ anchors: {
2687
+ productIds: normalizedPrepareParams.productIds,
2688
+ resourceIds: normalizedPrepareParams.resourceIds,
2689
+ dateRange
2690
+ },
2691
+ prepareParams: normalizedPrepareParams,
2692
+ durationCandidateAnchorAt
2693
+ });
2694
+ return {
2695
+ projection,
2696
+ prepareParams: normalizedPrepareParams,
2697
+ orderRevision
1099
2698
  };
1100
- const snapshot = this.getPlanner().setContext(context);
1101
- this.getPlanner().setSelectionPatch({
1102
- mode: params.mode,
1103
- productIds: params.productIds || [],
1104
- date: params.date,
1105
- startTime: params.startTime,
1106
- endTime: params.endTime,
1107
- resourceIds: params.resourceIds || []
2699
+ }
2700
+ async getSharedPreparedAvailabilitySnapshot(params) {
2701
+ const lifecycleGeneration = this.availabilityLifecycleGeneration;
2702
+ const normalizedParams = this.normalizeAvailabilityPrepareDescriptor(params);
2703
+ const hasRawInputs = params.rawProducts !== undefined || params.rawResources !== undefined || params.rawOccupancies !== undefined;
2704
+ const prepareKey = hasRawInputs ? JSON.stringify(stableValue({
2705
+ scope: this.getAvailabilityScopeDescriptor(),
2706
+ prepare: {
2707
+ ...normalizedParams,
2708
+ rawProducts: undefined,
2709
+ rawResources: undefined,
2710
+ rawOccupancies: undefined
2711
+ },
2712
+ rawInputs: {
2713
+ products: params.rawProducts !== undefined,
2714
+ resources: params.rawResources !== undefined,
2715
+ occupancies: params.rawOccupancies !== undefined
2716
+ }
2717
+ })) : this.buildAvailabilityPrepareKey(normalizedParams);
2718
+ if (hasRawInputs) {
2719
+ const snapshot = await this.buildAvailabilityProjectionSnapshot(normalizedParams);
2720
+ if (lifecycleGeneration !== this.availabilityLifecycleGeneration) {
2721
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'prepare 期间业务作用域或实例生命周期已变化,请重新 prepare', {
2722
+ prepareKey,
2723
+ expectedLifecycleGeneration: lifecycleGeneration,
2724
+ currentLifecycleGeneration: this.availabilityLifecycleGeneration
2725
+ });
2726
+ }
2727
+ return {
2728
+ prepareKey,
2729
+ snapshot
2730
+ };
2731
+ }
2732
+ const existing = this.availabilityPrepareInFlight.get(prepareKey);
2733
+ const promise = existing?.lifecycleGeneration === lifecycleGeneration ? existing.promise : this.buildAvailabilityProjectionSnapshot(normalizedParams);
2734
+ if (!existing || existing.lifecycleGeneration !== lifecycleGeneration) {
2735
+ this.availabilityPrepareInFlight.set(prepareKey, {
2736
+ lifecycleGeneration,
2737
+ prepareKey,
2738
+ promise
2739
+ });
2740
+ }
2741
+ try {
2742
+ const snapshot = await promise;
2743
+ if (lifecycleGeneration !== this.availabilityLifecycleGeneration) {
2744
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'prepare 期间业务作用域或实例生命周期已变化,请重新 prepare', {
2745
+ prepareKey,
2746
+ expectedLifecycleGeneration: lifecycleGeneration,
2747
+ currentLifecycleGeneration: this.availabilityLifecycleGeneration
2748
+ });
2749
+ }
2750
+ return {
2751
+ prepareKey,
2752
+ snapshot
2753
+ };
2754
+ } finally {
2755
+ const current = this.availabilityPrepareInFlight.get(prepareKey);
2756
+ if (current?.promise === promise) {
2757
+ this.availabilityPrepareInFlight.delete(prepareKey);
2758
+ }
2759
+ }
2760
+ }
2761
+ async refreshAvailabilityContextProjection(params) {
2762
+ const existingInFlight = this.availabilityProjectionRefreshInFlight.get(params.contextId);
2763
+ if (existingInFlight) return await existingInFlight;
2764
+ const context = this.getAvailabilityContextEntry(params.contextId);
2765
+ const expectedVersion = context.activeVersion;
2766
+ const expectedRefreshNonce = context.refreshNonce;
2767
+ const expectedLifecycleGeneration = this.availabilityLifecycleGeneration;
2768
+ const refreshPromise = (async () => {
2769
+ const snapshot = await this.buildAvailabilityProjectionSnapshot(this.cloneAvailabilityPrepareParams(context.prepareParams), {
2770
+ durationCandidateAnchorAt: context.durationCandidateAnchorAt
2771
+ });
2772
+ const currentContext = this.availabilityContextRegistry.get(params.contextId);
2773
+ if (!currentContext) return null;
2774
+ if (expectedLifecycleGeneration !== this.availabilityLifecycleGeneration || currentContext.activeVersion !== expectedVersion || currentContext.refreshNonce !== expectedRefreshNonce || currentContext.lifecycleGeneration !== expectedLifecycleGeneration) {
2775
+ return null;
2776
+ }
2777
+ const projectionEntry = this.storeAvailabilityProjectionEntry({
2778
+ contextId: currentContext.contextId,
2779
+ prepareParams: snapshot.prepareParams,
2780
+ prepareKey: currentContext.prepareKey,
2781
+ projection: snapshot.projection,
2782
+ orderRevision: snapshot.orderRevision,
2783
+ incrementVersion: true
2784
+ });
2785
+ await this.emitAvailabilityDiagnostic({
2786
+ action: 'refresh',
2787
+ contextId: currentContext.contextId,
2788
+ contextVersion: projectionEntry.contextVersion,
2789
+ projection: projectionEntry.projection,
2790
+ snapshotId: projectionEntry.snapshotId,
2791
+ orderRevision: projectionEntry.orderRevision,
2792
+ previousOrderRevision: params.previousOrderRevision,
2793
+ reason: params.reason
2794
+ });
2795
+ return projectionEntry;
2796
+ })();
2797
+ this.availabilityProjectionRefreshInFlight.set(params.contextId, refreshPromise);
2798
+ try {
2799
+ return await refreshPromise;
2800
+ } finally {
2801
+ const current = this.availabilityProjectionRefreshInFlight.get(params.contextId);
2802
+ if (current === refreshPromise) {
2803
+ this.availabilityProjectionRefreshInFlight.delete(params.contextId);
2804
+ }
2805
+ }
2806
+ }
2807
+ async ensureAvailabilityContextProjection(contextId, reason) {
2808
+ let retries = 0;
2809
+ while (retries < 3) {
2810
+ const context = this.getAvailabilityContextEntry(contextId);
2811
+ const currentOrderRevision = this.getCurrentOrderRevision();
2812
+ const projectionEntry = this.getActiveAvailabilityProjectionEntry(context);
2813
+ if (projectionEntry && projectionEntry.orderRevision === currentOrderRevision) {
2814
+ return {
2815
+ context,
2816
+ projectionEntry,
2817
+ refreshed: false
2818
+ };
2819
+ }
2820
+ const refreshedProjectionEntry = await this.refreshAvailabilityContextProjection({
2821
+ contextId,
2822
+ reason: projectionEntry ? reason : `${reason}:projection_cache_miss`,
2823
+ previousOrderRevision: context.lastKnownOrderRevision
2824
+ });
2825
+ if (!refreshedProjectionEntry) {
2826
+ retries += 1;
2827
+ continue;
2828
+ }
2829
+ if (refreshedProjectionEntry.orderRevision !== this.getCurrentOrderRevision()) {
2830
+ retries += 1;
2831
+ continue;
2832
+ }
2833
+ return {
2834
+ context: this.getAvailabilityContextEntry(contextId),
2835
+ projectionEntry: refreshedProjectionEntry,
2836
+ refreshed: true
2837
+ };
2838
+ }
2839
+ throw new _ResourcePlanner.AvailabilityError('STALE_PROJECTION', 'availability projection 刷新期间购物车持续变化,请重新 prepare', {
2840
+ contextId,
2841
+ reason
1108
2842
  });
1109
- await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onPlannerContextLoaded}`, snapshot);
1110
- return this.getPlanner().getSnapshot();
1111
2843
  }
1112
-
1113
- /** Returns cells, date summaries and a derived grid; cells are continuous ranges, not UI slots. */
1114
- async queryPlannerAvailability(query = {}) {
1115
- return this.getPlanner().queryAvailability(query);
2844
+ async tryAcquireAvailabilityCommitLock(contextId) {
2845
+ if (this.availabilityCommitLocks.has(contextId)) {
2846
+ const waitingContext = this.availabilityContextRegistry.get(contextId);
2847
+ await this.emitAvailabilityDiagnostic({
2848
+ action: 'commit_lock_wait',
2849
+ contextId,
2850
+ contextVersion: waitingContext?.activeVersion ?? null,
2851
+ projection: this.getAvailabilityProjectionByKey(waitingContext?.activeProjectionKey),
2852
+ snapshotId: waitingContext?.lastSnapshotId,
2853
+ orderRevision: waitingContext?.lastKnownOrderRevision,
2854
+ reason: 'AVAILABILITY_COMMIT_IN_PROGRESS'
2855
+ });
2856
+ throw this.createAvailabilityFacadeError('AVAILABILITY_COMMIT_IN_PROGRESS', '同一 availability context 已有 commit 正在进行中', {
2857
+ contextId
2858
+ });
2859
+ }
2860
+ this.availabilityCommitLocks.add(contextId);
2861
+ const currentContext = this.availabilityContextRegistry.get(contextId);
2862
+ await this.emitAvailabilityDiagnostic({
2863
+ action: 'commit_lock_acquired',
2864
+ contextId,
2865
+ contextVersion: currentContext?.activeVersion ?? null,
2866
+ projection: this.getAvailabilityProjectionByKey(currentContext?.activeProjectionKey),
2867
+ snapshotId: currentContext?.lastSnapshotId,
2868
+ orderRevision: currentContext?.lastKnownOrderRevision,
2869
+ reason: 'commit_start'
2870
+ });
2871
+ }
2872
+ async releaseAvailabilityCommitLock(contextId) {
2873
+ this.availabilityCommitLocks.delete(contextId);
2874
+ const releasedContext = this.availabilityContextRegistry.get(contextId);
2875
+ await this.emitAvailabilityDiagnostic({
2876
+ action: 'commit_lock_released',
2877
+ contextId,
2878
+ contextVersion: releasedContext?.activeVersion ?? null,
2879
+ projection: this.getAvailabilityProjectionByKey(releasedContext?.activeProjectionKey),
2880
+ snapshotId: releasedContext?.lastSnapshotId,
2881
+ orderRevision: releasedContext?.lastKnownOrderRevision,
2882
+ reason: 'commit_end'
2883
+ });
1116
2884
  }
1117
2885
 
1118
- /** Turns compatible availability ranges into user-selectable intervals and ready-to-commit assignments. */
1119
- resolveAssignableSlots(query = {}) {
1120
- return this.getPlanner().resolveAssignableSlots(query);
2886
+ /** IO boundary: loads catalog, schedules, resources, occupancies, and registers one context. */
2887
+ async prepareAvailabilityProjection(params) {
2888
+ this.assertAvailabilityPrepareParams(params);
2889
+ const paramsWithDefaults = await this.resolveAvailabilityPrepareDefaults(params);
2890
+ const {
2891
+ prepareKey,
2892
+ snapshot
2893
+ } = await this.getSharedPreparedAvailabilitySnapshot(paramsWithDefaults);
2894
+ const contextId = this.createAvailabilityContextId();
2895
+ const projectionEntry = this.storeAvailabilityProjectionEntry({
2896
+ contextId,
2897
+ prepareParams: snapshot.prepareParams,
2898
+ prepareKey,
2899
+ projection: snapshot.projection,
2900
+ orderRevision: snapshot.orderRevision,
2901
+ incrementVersion: false
2902
+ });
2903
+ const context = this.getAvailabilityContextEntry(contextId);
2904
+ const handle = this.createAvailabilityContextRef(context);
2905
+ const payload = {
2906
+ context: handle,
2907
+ contextVersion: context.activeVersion,
2908
+ projection: projectionEntry.projection,
2909
+ refreshed: false,
2910
+ reason: 'prepare'
2911
+ };
2912
+ await this.emitAvailabilityDiagnostic({
2913
+ action: 'prepare',
2914
+ contextId,
2915
+ contextVersion: context.activeVersion,
2916
+ projection: projectionEntry.projection,
2917
+ snapshotId: context.lastSnapshotId,
2918
+ orderRevision: context.lastKnownOrderRevision,
2919
+ reason: 'prepare'
2920
+ });
2921
+ await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilityProjectionPrepared}`, payload);
2922
+ return handle;
1121
2923
  }
1122
2924
 
1123
- /** Stores a UI choice only. It is deliberately side-effect free with respect to tempOrder. */
1124
- setPlannerSelection(patch) {
1125
- return this.getPlanner().setSelectionPatch(patch);
2925
+ /** Candidate calculation over the current context projection and runtime minute. */
2926
+ async getProductTimeRanges(contextId, request = {}) {
2927
+ const {
2928
+ context,
2929
+ projectionEntry
2930
+ } = await this.ensureAvailabilityContextProjection(contextId, 'get_product_time_ranges');
2931
+ const contextVersion = context.activeVersion;
2932
+ const allowOutsideOperatingHoursForRequestedStartTimes = String(this.otherParams?.platform ?? '').trim().toLowerCase() === 'pos' && Object.values(request.durationStartTimesByProductId || {}).some(startTimes => Array.isArray(startTimes) && startTimes.length > 0);
2933
+ return (0, _ResourcePlanner.getProductTimeRanges)(projectionEntry.projection, {
2934
+ ...request,
2935
+ ...(allowOutsideOperatingHoursForRequestedStartTimes ? {
2936
+ allowOutsideOperatingHoursForRequestedStartTimes: true
2937
+ } : {})
2938
+ }, {
2939
+ evaluatedAt: this.getAvailabilityRuntimeDateTime()
2940
+ }).map(group => ({
2941
+ ...group,
2942
+ ranges: group.ranges.map(candidate => this.decorateAvailabilityCandidate(context.contextId, contextVersion, candidate))
2943
+ }));
1126
2944
  }
1127
2945
 
1128
- /**
1129
- * Legacy-friendly automatic assignment for already fixed intervals. Duration products without a
1130
- * start time intentionally remain unresolved; callers offering a picker should use slots.
1131
- */
1132
- async autoAssignPlanner(query = {}) {
1133
- const result = this.getPlanner().autoAssign(query);
1134
- if (result.conflicts.length) {
1135
- console.warn('[UnifiedBookingSales] autoAssignPlanner 存在未分配项', result.conflicts);
2946
+ /** Thin facade: auto-refresh on cart drift, then delegate to the pure projection query. */
2947
+ async queryBookingAvailability(contextId, request) {
2948
+ const {
2949
+ context,
2950
+ projectionEntry,
2951
+ refreshed
2952
+ } = await this.ensureAvailabilityContextProjection(contextId, 'query_booking_availability');
2953
+ this.assertContextualQueryFreshness(context, request);
2954
+ const result = (0, _ResourcePlanner.queryAvailabilityProjection)(projectionEntry.projection, request);
2955
+ const contextVersion = context.activeVersion;
2956
+ const contextualResult = result.target === 'time' ? {
2957
+ ...result,
2958
+ data: result.data.map(group => ({
2959
+ ...group,
2960
+ times: group.times.map(item => ({
2961
+ ...item,
2962
+ availabilityContextId: context.contextId,
2963
+ availabilityContextVersion: contextVersion
2964
+ }))
2965
+ }))
2966
+ } : result.target === 'resource' ? {
2967
+ ...result,
2968
+ data: result.data.map(group => ({
2969
+ ...group,
2970
+ ranges: group.ranges.map(rangeGroup => ({
2971
+ ...rangeGroup,
2972
+ range: {
2973
+ ...rangeGroup.range,
2974
+ availabilityContextId: context.contextId,
2975
+ availabilityContextVersion: contextVersion
2976
+ }
2977
+ }))
2978
+ }))
2979
+ } : result;
2980
+ return {
2981
+ ...contextualResult,
2982
+ contextId: context.contextId,
2983
+ contextVersion,
2984
+ refreshed
2985
+ };
2986
+ }
2987
+ releaseAvailabilityContext(contextId) {
2988
+ const context = this.availabilityContextRegistry.get(contextId);
2989
+ if (!context) return;
2990
+ this.removeAvailabilityContext(contextId);
2991
+ void this.emitAvailabilityDiagnostic({
2992
+ action: 'release',
2993
+ contextId,
2994
+ contextVersion: context.activeVersion,
2995
+ projection: this.getActiveAvailabilityProjectionEntry(context)?.projection || null,
2996
+ snapshotId: context.lastSnapshotId,
2997
+ orderRevision: context.lastKnownOrderRevision,
2998
+ reason: 'explicit_release'
2999
+ });
3000
+ }
3001
+ assertAvailabilityCommitWriteCAS(params) {
3002
+ const context = this.availabilityContextRegistry.get(params.contextId);
3003
+ if (!context) {
3004
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'availability context 不存在或已失效,请重新 prepare', {
3005
+ contextId: params.contextId
3006
+ });
1136
3007
  }
1137
- return this.getPlanner().getSnapshot();
3008
+ if (this.availabilityLifecycleGeneration !== params.expectedLifecycleGeneration || context.lifecycleGeneration !== params.expectedLifecycleGeneration || context.activeVersion !== params.expectedVersion || context.refreshNonce !== params.expectedRefreshNonce || this.getCurrentOrderRevision() !== params.expectedOrderRevision) {
3009
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已变化,当前选择已失效,请重新获取候选', {
3010
+ contextId: params.contextId,
3011
+ expectedVersion: params.expectedVersion,
3012
+ currentVersion: context.activeVersion,
3013
+ expectedRefreshNonce: params.expectedRefreshNonce,
3014
+ currentRefreshNonce: context.refreshNonce,
3015
+ expectedLifecycleGeneration: params.expectedLifecycleGeneration,
3016
+ currentLifecycleGeneration: this.availabilityLifecycleGeneration,
3017
+ expectedOrderRevision: params.expectedOrderRevision,
3018
+ currentOrderRevision: this.getCurrentOrderRevision()
3019
+ });
3020
+ }
3021
+ return context;
1138
3022
  }
1139
-
1140
- /** Rechecks remote events, capacity and conflicts among the current selection before committing. */
1141
- validatePlannerSelection() {
1142
- return this.getPlanner().validateSelection();
3023
+ findProductLineByUid(productLineUid) {
3024
+ return (this.getTempOrder()?.products || []).find(product => getProductLineUid(product) === String(productLineUid));
1143
3025
  }
1144
-
1145
- /** Read-only snapshot for UI diagnostics and progressive flow steps. */
1146
- getPlannerSnapshot() {
1147
- return this.getPlanner().getSnapshot();
3026
+ findLinkedBooking(product) {
3027
+ const productUid = getProductLineUid(product);
3028
+ const bookingUid = String(product.booking_uid ?? product.metadata?.booking_uid ?? '');
3029
+ return (this.getTempOrder()?.bookings || []).find(booking => bookingUid && getBookingUid(booking) === bookingUid || productUid && String(booking.product_uid ?? '') === productUid);
1148
3030
  }
1149
3031
 
1150
- /**
1151
- * Validates then writes assignments to tempOrder. Real order submission is opt-in so UI callers
1152
- * can safely preview the cart; pass submitAfterCommit only after an explicit confirmation.
1153
- */
1154
- async commitPlannerSelection(params = {}) {
1155
- if (params.selection) {
1156
- this.setPlannerSelection(params.selection);
1157
- }
1158
- if (params.assignments) {
1159
- this.setPlannerSelection({
1160
- assignments: params.assignments
3032
+ /** Revalidates against the latest landed occupancy cache, then mutates exactly one product line. */
3033
+ async commitAvailabilitySelection(contextId, params) {
3034
+ const normalizedContextId = String(contextId || '').trim();
3035
+ if (!normalizedContextId) {
3036
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'commitAvailabilitySelection 缺少 contextId', {
3037
+ candidate: params.candidate
1161
3038
  });
1162
3039
  }
1163
- const snapshot = this.getPlannerSnapshot();
1164
- const assignments = snapshot.selection.assignments || [];
1165
- if (assignments.length === 0) {
1166
- return {
1167
- tempOrder: this.getTempOrder(),
1168
- assignments,
1169
- validation: {
1170
- ok: false,
1171
- conflicts: [{
1172
- type: 'missing_assignment',
1173
- message: '没有可提交的 planner assignment,请先执行 autoAssignPlanner 或传入 assignments'
1174
- }]
3040
+ await this.tryAcquireAvailabilityCommitLock(normalizedContextId);
3041
+ try {
3042
+ const bookingCount = Number(params.bookingCount ?? 1);
3043
+ if (!Number.isInteger(bookingCount) || bookingCount <= 0) {
3044
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'bookingCount 必须是正整数', {
3045
+ bookingCount: params.bookingCount
3046
+ });
3047
+ }
3048
+ if (params.mode === 'edit' && bookingCount !== 1) {
3049
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', '编辑预约商品时 bookingCount 必须为 1', {
3050
+ bookingCount
3051
+ });
3052
+ }
3053
+ const orderProductQuantity = params.orderProduct?.num ?? params.orderProduct?.quantity ?? params.orderProduct?._extend?.quantity;
3054
+ if (orderProductQuantity !== undefined && orderProductQuantity !== null && Number(orderProductQuantity) !== bookingCount) {
3055
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'orderProduct 数量与 bookingCount 不一致', {
3056
+ bookingCount,
3057
+ orderProductQuantity
3058
+ });
3059
+ }
3060
+ const partySize = Number(params.partySize);
3061
+ if (!Number.isInteger(partySize) || partySize <= 0) {
3062
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'partySize 必须是正整数', {
3063
+ partySize: params.partySize
3064
+ });
3065
+ }
3066
+ const {
3067
+ context,
3068
+ projectionEntry,
3069
+ refreshed
3070
+ } = await this.ensureAvailabilityContextProjection(normalizedContextId, 'commit_precheck');
3071
+ if (refreshed) {
3072
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已刷新,请重新获取候选', {
3073
+ contextId: normalizedContextId,
3074
+ currentContextVersion: context.activeVersion,
3075
+ candidateContextVersion: params.candidate.availabilityContextVersion
3076
+ });
3077
+ }
3078
+ const boundEditingUids = context.prepareParams.editingProductLineUids || [];
3079
+ if (params.mode === 'edit') {
3080
+ if (!params.productLineUid || !boundEditingUids.includes(String(params.productLineUid))) {
3081
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'edit context 与当前商品行不匹配,请重新 prepare 编辑上下文', {
3082
+ contextId: normalizedContextId,
3083
+ productLineUid: params.productLineUid,
3084
+ editingProductLineUids: boundEditingUids
3085
+ });
1175
3086
  }
3087
+ } else if (boundEditingUids.length > 0) {
3088
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'edit context 不能用于 add,请重新 prepare 新增上下文', {
3089
+ contextId: normalizedContextId,
3090
+ editingProductLineUids: boundEditingUids
3091
+ });
3092
+ }
3093
+ if (projectionEntry.contextVersion !== context.activeVersion) {
3094
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已刷新,请重新获取候选', {
3095
+ contextId: normalizedContextId,
3096
+ currentContextVersion: context.activeVersion,
3097
+ projectionContextVersion: projectionEntry.contextVersion
3098
+ });
3099
+ }
3100
+ if (projectionEntry.orderRevision !== this.getCurrentOrderRevision()) {
3101
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '购物车已变化,请重新获取候选', {
3102
+ contextId: normalizedContextId,
3103
+ currentContextVersion: context.activeVersion,
3104
+ projectionOrderRevision: projectionEntry.orderRevision,
3105
+ currentOrderRevision: this.getCurrentOrderRevision()
3106
+ });
3107
+ }
3108
+ if (projectionEntry.contextVersion !== params.candidate.availabilityContextVersion) {
3109
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选已过期,请重新获取最新时间段', {
3110
+ contextId: normalizedContextId,
3111
+ candidateContextVersion: params.candidate.availabilityContextVersion,
3112
+ currentContextVersion: context.activeVersion
3113
+ });
3114
+ }
3115
+ this.assertAvailabilityCandidateFreshness(context, params.candidate);
3116
+ const orderProductId = params.orderProduct?.product_id ?? params.orderProduct?.id;
3117
+ if (!sameAvailabilityId(orderProductId, params.candidate.productId)) {
3118
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'orderProduct 与 candidate 商品不一致', {
3119
+ orderProductId,
3120
+ candidateProductId: params.candidate.productId
3121
+ });
3122
+ }
3123
+ if (params.mode === 'edit' && !params.productLineUid) {
3124
+ throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', 'edit 模式必须提供 productLineUid');
3125
+ }
3126
+ const editingLine = params.mode === 'edit' ? this.findProductLineByUid(String(params.productLineUid)) : undefined;
3127
+ if (params.mode === 'edit' && !editingLine) {
3128
+ throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', '找不到要编辑的预约商品行', {
3129
+ productLineUid: params.productLineUid
3130
+ });
3131
+ }
3132
+ if (editingLine && !sameAvailabilityId(editingLine.product_id ?? editingLine.id, params.candidate.productId)) {
3133
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', '编辑商品行与 candidate 商品不一致', {
3134
+ productLineUid: params.productLineUid,
3135
+ productLineProductId: editingLine.product_id ?? editingLine.id,
3136
+ candidateProductId: params.candidate.productId
3137
+ });
3138
+ }
3139
+ if (!Array.isArray(params.requirementSelections)) {
3140
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'requirementSelections 必须是数组', {
3141
+ requirementSelections: params.requirementSelections
3142
+ });
3143
+ }
3144
+ const baseProjection = projectionEntry.projection;
3145
+ let commitWriteGuard = {
3146
+ contextId: normalizedContextId,
3147
+ expectedVersion: context.activeVersion,
3148
+ expectedRefreshNonce: context.refreshNonce,
3149
+ expectedLifecycleGeneration: this.availabilityLifecycleGeneration,
3150
+ expectedOrderRevision: String(baseProjection.meta.orderRevision ?? '')
1176
3151
  };
1177
- }
1178
- const validation = this.validatePlannerSelection();
1179
- if (!validation.ok) {
1180
- return {
1181
- tempOrder: this.getTempOrder(),
1182
- assignments,
1183
- validation
3152
+ const prepareParams = baseProjection.meta.prepareParams;
3153
+ if (!prepareParams) {
3154
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', 'projection 缺少 prepareParams,无法执行缓存重校验');
3155
+ }
3156
+ const fallbackResources = {
3157
+ resources: (0, _lodashEs.cloneDeep)([...(baseProjection.resources || [])]),
3158
+ resourceOccupancies: (0, _lodashEs.cloneDeep)([...(baseProjection.resourceOccupancies || [])].filter(occupancy => occupancy.source !== 'cart')),
3159
+ includesInjectedRawOccupancies: true
1184
3160
  };
1185
- }
1186
- const productsById = new Map(snapshot.context.products.map(product => [String(product.id), product]));
1187
- await this.removeExistingStagedLines('unified_booking_sales');
1188
- const assignmentGroups = new Map();
1189
- assignments.forEach(assignment => {
1190
- const key = buildAssignmentGroupKey(assignment);
1191
- assignmentGroups.set(key, [...(assignmentGroups.get(key) || []), assignment]);
1192
- });
1193
- for (const groupAssignments of assignmentGroups.values()) {
1194
- const product = productsById.get(String(groupAssignments[0].productId));
1195
- if (!product) continue;
1196
- const sourceProduct = product.raw || {
1197
- id: product.id,
1198
- title: product.title,
1199
- price: product.price
3161
+ const commitSnapshot = await this.buildAvailabilityProjectionSnapshot({
3162
+ ...this.cloneAvailabilityPrepareParams(prepareParams),
3163
+ editingProductLineUids: params.mode === 'edit' && params.productLineUid ? [params.productLineUid] : []
3164
+ }, {
3165
+ resourceFetchPolicy: 'cache_only',
3166
+ fallbackResources,
3167
+ durationCandidateAnchorAt: this.getAvailabilityDurationCandidateAnchorAt(baseProjection) || context.durationCandidateAnchorAt
3168
+ });
3169
+ const refreshedProjection = commitSnapshot.projection;
3170
+ if (String(refreshedProjection.meta.orderRevision ?? '') !== String(baseProjection.meta.orderRevision ?? '')) {
3171
+ await this.refreshAvailabilityContextProjection({
3172
+ contextId: normalizedContextId,
3173
+ reason: 'commit_order_revision_changed',
3174
+ previousOrderRevision: String(baseProjection.meta.orderRevision ?? '')
3175
+ });
3176
+ const latestContext = this.getAvailabilityContextEntry(normalizedContextId);
3177
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '最终缓存重校验期间购物车已变化,请重新获取候选', {
3178
+ contextId: normalizedContextId,
3179
+ candidateContextVersion: params.candidate.availabilityContextVersion,
3180
+ currentContextVersion: latestContext.activeVersion,
3181
+ projectionOrderRevision: baseProjection.meta.orderRevision,
3182
+ refreshedOrderRevision: refreshedProjection.meta.orderRevision,
3183
+ snapshotId: baseProjection.meta.snapshotId,
3184
+ refreshedSnapshotId: refreshedProjection.meta.snapshotId
3185
+ });
3186
+ }
3187
+ this.assertAvailabilityCommitWriteCAS(commitWriteGuard);
3188
+ const candidateDurationMinutes = getCandidateDurationMinutes(params.candidate);
3189
+ const candidateStartTime = getCandidateStartTime(params.candidate);
3190
+ const allowPosAvailabilityOverride = String(this.otherParams?.platform ?? '').trim().toLowerCase() === 'pos';
3191
+ const refreshedRanges = (0, _ResourcePlanner.getProductTimeRanges)(refreshedProjection, {
3192
+ productIds: [params.candidate.productId],
3193
+ dateRange: {
3194
+ startDate: params.candidate.startAt.slice(0, 10),
3195
+ endDate: params.candidate.startAt.slice(0, 10)
3196
+ },
3197
+ ...(candidateDurationMinutes ? {
3198
+ durationMinutesByProductId: {
3199
+ [String(params.candidate.productId)]: candidateDurationMinutes
3200
+ },
3201
+ ...(candidateStartTime ? {
3202
+ durationStartTimesByProductId: {
3203
+ [String(params.candidate.productId)]: [candidateStartTime]
3204
+ },
3205
+ ...(allowPosAvailabilityOverride ? {
3206
+ allowOutsideOperatingHoursForRequestedStartTimes: true
3207
+ } : {})
3208
+ } : {})
3209
+ } : {})
3210
+ }).flatMap(group => group.ranges);
3211
+ const refreshedCandidateBase = refreshedRanges.find(candidate => candidate.startAt === params.candidate.startAt && candidate.endAt === params.candidate.endAt && candidate.bookingStartAt === params.candidate.bookingStartAt && candidate.bookingEndAt === params.candidate.bookingEndAt);
3212
+ if (!refreshedCandidateBase) {
3213
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', '缓存重校验后候选时间已不在商品配置中', {
3214
+ contextId: normalizedContextId,
3215
+ candidate: params.candidate,
3216
+ refreshedSnapshotId: refreshedProjection.meta.snapshotId
3217
+ });
3218
+ }
3219
+ const product = refreshedProjection.products.find(item => sameAvailabilityId(item.id, refreshedCandidateBase.productId));
3220
+ if (!product) {
3221
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', '缓存重校验后商品不在 projection 中', {
3222
+ productId: refreshedCandidateBase.productId
3223
+ });
3224
+ }
3225
+ let validation = (0, _ResourcePlanner.validateAvailabilitySelection)(refreshedProjection, {
3226
+ candidate: refreshedCandidateBase,
3227
+ requirementSelections: params.requirementSelections,
3228
+ partySize,
3229
+ bookingCount
3230
+ });
3231
+ if (allowPosAvailabilityOverride && !validation.ok && validation.conflicts.every(conflict => POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES.has(conflict.code))) {
3232
+ const forced = buildPosForcedAvailabilityAssignments({
3233
+ product,
3234
+ resources: refreshedProjection.resources,
3235
+ candidate: refreshedCandidateBase,
3236
+ requirementSelections: params.requirementSelections,
3237
+ partySize
3238
+ });
3239
+ validation = forced.conflicts.length > 0 ? {
3240
+ ...validation,
3241
+ assignments: [],
3242
+ conflicts: forced.conflicts
3243
+ } : {
3244
+ ...validation,
3245
+ ok: true,
3246
+ status: validation.availability.status,
3247
+ assignments: forced.assignments,
3248
+ conflicts: []
3249
+ };
3250
+ }
3251
+ if (!validation.ok) {
3252
+ const capacityConflict = validation.conflicts.find(conflict => conflict.code === 'resource_capacity_exceeded');
3253
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', capacityConflict?.message || '缓存重校验后资源已不可提交', {
3254
+ validation,
3255
+ refreshedSnapshotId: refreshedProjection.meta.snapshotId
3256
+ });
3257
+ }
3258
+ // Revalidation failure must leave the caller's candidate reusable. Only publish the
3259
+ // refreshed projection/version after candidate and resource validation has passed;
3260
+ // otherwise a second Confirm from the still-open modal would incorrectly become stale.
3261
+ const revalidationEntry = this.storeAvailabilityProjectionEntry({
3262
+ contextId: normalizedContextId,
3263
+ prepareParams: commitSnapshot.prepareParams,
3264
+ prepareKey: context.prepareKey,
3265
+ projection: refreshedProjection,
3266
+ orderRevision: commitSnapshot.orderRevision,
3267
+ incrementVersion: true
3268
+ });
3269
+ const revalidatedContext = this.getAvailabilityContextEntry(normalizedContextId);
3270
+ await this.emitAvailabilityDiagnostic({
3271
+ action: 'refresh',
3272
+ contextId: normalizedContextId,
3273
+ contextVersion: revalidatedContext.activeVersion,
3274
+ projection: refreshedProjection,
3275
+ snapshotId: revalidationEntry.snapshotId,
3276
+ orderRevision: revalidationEntry.orderRevision,
3277
+ previousOrderRevision: baseProjection.meta.orderRevision,
3278
+ reason: 'commit_cached_occupancy_revalidation'
3279
+ });
3280
+ commitWriteGuard = {
3281
+ contextId: normalizedContextId,
3282
+ expectedVersion: revalidatedContext.activeVersion,
3283
+ expectedRefreshNonce: revalidatedContext.refreshNonce,
3284
+ expectedLifecycleGeneration: this.availabilityLifecycleGeneration,
3285
+ expectedOrderRevision: revalidationEntry.orderRevision
1200
3286
  };
1201
- const productInput = this.buildUnifiedOrderProduct(sourceProduct, 'unified_booking_sales', product);
1202
- if (!productInput) continue;
1203
- await this.addProductToOrder(productInput, buildBookingFromAssignments({
1204
- assignments: groupAssignments,
1205
- product
1206
- }));
1207
- }
1208
- let submitResult;
1209
- if (params.submitAfterCommit) {
1210
- submitResult = await this.submitSalesOrder();
3287
+ const refreshedCandidate = this.decorateAvailabilityCandidate(normalizedContextId, revalidatedContext.activeVersion, refreshedCandidateBase);
3288
+ const existingBooking = editingLine ? this.findLinkedBooking(editingLine) : undefined;
3289
+ const existingBookingUid = existingBooking ? getBookingUid(existingBooking) : '';
3290
+ if (params.mode === 'edit' && !existingBookingUid) {
3291
+ throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', '编辑商品行缺少关联 booking', {
3292
+ productLineUid: params.productLineUid
3293
+ });
3294
+ }
3295
+ this.assertAvailabilityCommitWriteCAS(commitWriteGuard);
3296
+ const booking = buildBookingFromAvailabilitySelection({
3297
+ assignments: validation.assignments,
3298
+ candidate: refreshedCandidate,
3299
+ product,
3300
+ resources: refreshedProjection.resources,
3301
+ bookingUid: existingBookingUid || undefined,
3302
+ holder: params.orderProduct?._extend?.holder ?? existingBooking?.holder ?? existingBooking?.metadata?.holder,
3303
+ holderIds: params.orderProduct?._extend?.holder_id ?? params.orderProduct?._extend?.holder?.form_record ?? existingBooking?.metadata?.holder_id ?? existingBooking?.holder?.form_record
3304
+ });
3305
+ const transformed = this.buildUnifiedOrderProduct({
3306
+ ...params.orderProduct,
3307
+ num: bookingCount,
3308
+ quantity: bookingCount
3309
+ }, 'unified_booking_sales', product);
3310
+ if (!transformed) {
3311
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'orderProduct 无法转换为订单行', {
3312
+ orderProduct: params.orderProduct
3313
+ });
3314
+ }
3315
+ transformed.num = bookingCount;
3316
+ transformed.metadata = {
3317
+ ...(transformed.metadata || {}),
3318
+ unified_booking_sales: true,
3319
+ availability_candidate_key: refreshedCandidate.key,
3320
+ availability_context_id: normalizedContextId,
3321
+ availability_context_version: refreshedCandidate.availabilityContextVersion
3322
+ };
3323
+ let productLineUid = '';
3324
+ let bookingUid = '';
3325
+ let productLineUids = [];
3326
+ let bookingUids = [];
3327
+ if (params.mode === 'edit' && editingLine && params.productLineUid) {
3328
+ transformed.product_sku = transformed.product_sku ?? editingLine.product_sku;
3329
+ transformed.product_bundle = transformed.product_bundle ?? editingLine.product_bundle;
3330
+ transformed.booking_uid = existingBookingUid;
3331
+ transformed.metadata = {
3332
+ ...(editingLine.metadata || {}),
3333
+ ...(transformed.metadata || {}),
3334
+ unique_identification_number: params.productLineUid,
3335
+ booking_uid: existingBookingUid
3336
+ };
3337
+ await this.updateOrderProduct({
3338
+ product_id: transformed.product_id ?? editingLine.product_id,
3339
+ product_variant_id: transformed.product_variant_id ?? editingLine.product_variant_id ?? 0,
3340
+ unique_identification_number: params.productLineUid,
3341
+ updates: transformed,
3342
+ booking,
3343
+ ...(params.allowBookingReprice !== undefined ? {
3344
+ allow_booking_reprice: params.allowBookingReprice
3345
+ } : {}),
3346
+ ...(params.allowBookingDiscountReapply !== undefined ? {
3347
+ allow_booking_discount_reapply: params.allowBookingDiscountReapply
3348
+ } : {})
3349
+ });
3350
+ productLineUid = params.productLineUid;
3351
+ bookingUid = existingBookingUid;
3352
+ productLineUids = [productLineUid];
3353
+ bookingUids = [bookingUid];
3354
+ } else {
3355
+ const beforeProductLineUids = new Set((this.getTempOrder()?.products || []).map(line => getProductLineUid(line)).filter(Boolean));
3356
+ const beforeBookingUids = new Set((this.getTempOrder()?.bookings || []).map(item => getBookingUid(item)).filter(Boolean));
3357
+ const products = await this.addProductToOrder(transformed, booking, params.salesNotes?.length ? {
3358
+ salesNotes: params.salesNotes
3359
+ } : undefined);
3360
+ const addedLines = products.filter(line => sameAvailabilityId(line.product_id ?? line.id, refreshedCandidate.productId) && line.metadata?.availability_candidate_key === refreshedCandidate.key && !beforeProductLineUids.has(getProductLineUid(line)));
3361
+ productLineUids = addedLines.map(line => getProductLineUid(line)).filter(Boolean);
3362
+ const addedBookings = (this.getTempOrder()?.bookings || []).filter(item => item.metadata?.availability_candidate_key === refreshedCandidate.key && !beforeBookingUids.has(getBookingUid(item)));
3363
+ bookingUids = addedBookings.map(item => getBookingUid(item)).filter(Boolean);
3364
+ if (bookingUids.length === 0) {
3365
+ bookingUids = addedLines.map(line => String(line.booking_uid ?? line.metadata?.booking_uid ?? '')).filter(Boolean);
3366
+ }
3367
+ productLineUid = productLineUids[productLineUids.length - 1] || '';
3368
+ bookingUid = bookingUids[bookingUids.length - 1] || '';
3369
+ }
3370
+ this.invalidateAvailabilityContextsAfterCommit();
3371
+ const nextContext = this.getAvailabilityContextEntry(normalizedContextId);
3372
+ const result = {
3373
+ contextId: normalizedContextId,
3374
+ contextVersion: nextContext.activeVersion,
3375
+ tempOrder: this.getTempOrder(),
3376
+ validation,
3377
+ candidate: refreshedCandidate,
3378
+ productLineUid,
3379
+ bookingUid,
3380
+ productLineUids,
3381
+ bookingUids
3382
+ };
3383
+ await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilitySelectionCommitted}`, result);
3384
+ return result;
3385
+ } finally {
3386
+ await this.releaseAvailabilityCommitLock(normalizedContextId);
1211
3387
  }
1212
- const result = {
1213
- tempOrder: this.getTempOrder(),
1214
- assignments,
1215
- validation,
1216
- submitResult
1217
- };
1218
- await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onPlannerCommitted}`, result);
1219
- return result;
1220
3388
  }
1221
3389
  }
1222
3390
  exports.UnifiedBookingSalesImpl = UnifiedBookingSalesImpl;