@pisell/pisellos 2.2.292 → 2.2.294

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/modules/BookingContext/index.d.ts +3 -0
  10. package/dist/modules/BookingContext/index.js +42 -13
  11. package/dist/modules/BookingContext/types.d.ts +4 -4
  12. package/dist/modules/BookingContext/types.js +3 -3
  13. package/dist/modules/OpenData/types.d.ts +1 -0
  14. package/dist/modules/Order/index.d.ts +19 -4
  15. package/dist/modules/Order/index.js +885 -485
  16. package/dist/modules/Order/salesNotes.d.ts +31 -0
  17. package/dist/modules/Order/salesNotes.js +492 -0
  18. package/dist/modules/Order/types.d.ts +93 -5
  19. package/dist/modules/Order/types.js +5 -0
  20. package/dist/modules/Order/utils.d.ts +12 -0
  21. package/dist/modules/Order/utils.js +96 -31
  22. package/dist/modules/Product/types.d.ts +2 -0
  23. package/dist/modules/ProductList/clientDataVariants.d.ts +50 -0
  24. package/dist/modules/ProductList/clientDataVariants.js +216 -0
  25. package/dist/modules/ProductList/index.d.ts +18 -3
  26. package/dist/modules/ProductList/index.js +220 -38
  27. package/dist/modules/ProductList/types.d.ts +10 -0
  28. package/dist/modules/Quotation/index.d.ts +22 -0
  29. package/dist/modules/Quotation/index.js +293 -28
  30. package/dist/modules/ResourcePlanner/index.d.ts +0 -22
  31. package/dist/modules/ResourcePlanner/index.js +1 -180
  32. package/dist/modules/ResourcePlanner/localClock.d.ts +33 -0
  33. package/dist/modules/ResourcePlanner/localClock.js +183 -0
  34. package/dist/modules/ResourcePlanner/planner.d.ts +5 -14
  35. package/dist/modules/ResourcePlanner/planner.js +2111 -958
  36. package/dist/modules/ResourcePlanner/types.d.ts +359 -187
  37. package/dist/modules/ResourcePlanner/types.js +33 -1
  38. package/dist/plugins/request.d.ts +1 -0
  39. package/dist/server/index.d.ts +17 -2
  40. package/dist/server/index.js +1134 -932
  41. package/dist/server/modules/floor-plan/index.d.ts +4 -0
  42. package/dist/server/modules/floor-plan/index.js +240 -98
  43. package/dist/server/modules/menu/index.js +48 -23
  44. package/dist/server/modules/order/index.d.ts +17 -7
  45. package/dist/server/modules/order/index.js +419 -207
  46. package/dist/server/modules/products/index.d.ts +10 -4
  47. package/dist/server/modules/products/index.js +177 -84
  48. package/dist/server/modules/resource/index.js +21 -13
  49. package/dist/server/utils/product.d.ts +1 -1
  50. package/dist/server/utils/product.js +186 -22
  51. package/dist/server/utils/small-ticket.d.ts +9 -1
  52. package/dist/server/utils/small-ticket.js +136 -41
  53. package/dist/solution/BaseSales/index.d.ts +26 -3
  54. package/dist/solution/BaseSales/index.js +1384 -905
  55. package/dist/solution/BaseSales/types.d.ts +2 -0
  56. package/dist/solution/BaseSales/utils/transformBaseProductToOrderProduct.d.ts +3 -0
  57. package/dist/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +10 -3
  58. package/dist/solution/BaseSales/utils.js +6 -3
  59. package/dist/solution/BookingByStep/index.d.ts +4 -4
  60. package/dist/solution/BookingTicket/index.d.ts +1 -1
  61. package/dist/solution/BookingTicket/index.js +4 -2
  62. package/dist/solution/BookingTicket/utils/addProductDecision.d.ts +2 -1
  63. package/dist/solution/BookingTicket/utils/addProductDecision.js +6 -3
  64. package/dist/solution/BookingTicket/utils/orderCustomer.js +3 -2
  65. package/dist/solution/RegisterAndLogin/config.d.ts +9 -3
  66. package/dist/solution/RegisterAndLogin/config.js +95 -40
  67. package/dist/solution/RegisterAndLogin/index.js +4 -1
  68. package/dist/solution/ScanOrder/index.d.ts +1 -0
  69. package/dist/solution/ScanOrder/index.js +31 -27
  70. package/dist/solution/ScanOrder/types.d.ts +2 -0
  71. package/dist/solution/ScanOrder/utils.d.ts +1 -0
  72. package/dist/solution/ScanOrder/utils.js +8 -3
  73. package/dist/solution/UnifiedBookingSales/index.d.ts +130 -76
  74. package/dist/solution/UnifiedBookingSales/index.js +3882 -966
  75. package/dist/solution/UnifiedBookingSales/remoteOccupancyCache.d.ts +49 -0
  76. package/dist/solution/UnifiedBookingSales/remoteOccupancyCache.js +430 -0
  77. package/dist/solution/UnifiedBookingSales/types.d.ts +161 -77
  78. package/dist/solution/UnifiedBookingSales/types.js +4 -9
  79. package/dist/solution/VenueBooking/index.d.ts +3 -0
  80. package/dist/solution/VenueBooking/index.js +549 -497
  81. package/dist/solution/VenueBooking/utils/dateSummary.d.ts +1 -0
  82. package/dist/solution/VenueBooking/utils/dateSummary.js +3 -1
  83. package/dist/solution/VenueBooking/utils/slotMerge.d.ts +2 -0
  84. package/dist/solution/VenueBooking/utils/slotMerge.js +3 -1
  85. package/dist/utils/platform.d.ts +8 -0
  86. package/dist/utils/platform.js +15 -0
  87. package/lib/core/index.d.ts +7 -0
  88. package/lib/core/index.js +20 -2
  89. package/lib/model/strategy/adapter/itemRule/adapter.d.ts +4 -1
  90. package/lib/model/strategy/adapter/itemRule/adapter.js +73 -4
  91. package/lib/model/strategy/adapter/itemRule/evaluator.d.ts +8 -1
  92. package/lib/model/strategy/adapter/itemRule/evaluator.js +23 -1
  93. package/lib/model/strategy/adapter/itemRule/type.d.ts +44 -0
  94. package/lib/model/strategy/adapter/itemRule/type.js +19 -1
  95. package/lib/model/strategy/adapter/promotion/index.js +46 -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 +19 -4
  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 +126 -33
  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 +130 -76
  161. package/lib/solution/UnifiedBookingSales/index.js +2597 -427
  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
 
@@ -512,12 +1548,14 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
512
1548
  }
513
1549
 
514
1550
  /**
515
- * Kiosk 不能把已收款订单留在本地等待重试:默认直接等待云端 checkout。
516
- * 兼容仍需要 WebPOS 待同步模式的入口可显式传 checkoutSyncTaskEnabled: true。
1551
+ * H5/Kiosk 不能把已收款订单留在本地等待重试,必须直接等待云端 checkout。
1552
+ * 其它平台保持 WebPOS/POS 的本地待同步任务;显式 true 仍可强制使用本地任务。
517
1553
  */
518
1554
  shouldUseCheckoutSyncTask() {
519
1555
  const configured = this.otherParams?.checkoutSyncTaskEnabled;
520
- return typeof configured === 'boolean' ? configured : false;
1556
+ if (configured === true) return true;
1557
+ const platform = (0, _platform.normalizeRuntimePlatform)(this.otherParams?.platform);
1558
+ return platform !== 'h5' && platform !== 'kiosk';
521
1559
  }
522
1560
 
523
1561
  /** UnifiedBookingSales 的真实结账默认请求收据数据,供云端确认后再本地打印。 */
@@ -525,12 +1563,9 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
525
1563
  return 1;
526
1564
  }
527
1565
  getRegisteredModuleNames() {
528
- return [...super.getRegisteredModuleNames(), 'resourcePlanner', 'openData'];
1566
+ return [...super.getRegisteredModuleNames(), 'openData'];
529
1567
  }
530
1568
  createSubModule(moduleName) {
531
- if (moduleName === 'resourcePlanner') {
532
- return new _modules.ResourcePlannerModule(`${this.name}_resourcePlanner`);
533
- }
534
1569
  if (moduleName === 'openData') {
535
1570
  return new _modules.OpenDataModule(`${this.name}_openData`);
536
1571
  }
@@ -582,8 +1617,7 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
582
1617
  return true;
583
1618
  }
584
1619
  isAuthenticatedCustomerUserPlatform() {
585
- const platform = String(this.otherParams?.platform || '').toLowerCase();
586
- return platform === 'pc' || platform === 'h5';
1620
+ return (0, _platform.isCustomerUserPlatform)(this.otherParams?.platform);
587
1621
  }
588
1622
  resolveDiscountOrderIdentity(tempOrder) {
589
1623
  return tempOrder?.order_id || this.store.order?.getOrderIdentity?.()?.orderId || null;
@@ -675,6 +1709,113 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
675
1709
  channel
676
1710
  });
677
1711
  }
1712
+ resetAllergyItemRuleState() {
1713
+ this.allergyItemRuleConfigs = [];
1714
+ this.allergyItemRuleConfigsPromise = null;
1715
+ this.allergyItemRuleEvaluator.setStrategyConfigs([]);
1716
+ }
1717
+ async fetchAllergyItemRuleConfigs(strategyModelIds) {
1718
+ if (!strategyModelIds.length) return [];
1719
+ try {
1720
+ const response = await this.request.get('/promotion', {
1721
+ skip: 1,
1722
+ num: 99,
1723
+ status: 'active',
1724
+ ids: strategyModelIds
1725
+ });
1726
+ if (response?.code !== 200 || !Array.isArray(response?.data?.list)) {
1727
+ throw new Error('[UnifiedBookingSales] 商品过敏策略加载失败');
1728
+ }
1729
+ const list = response.data.list;
1730
+ return normalizeItemRuleStrategyConfigs(list.map(item => item?.metadata));
1731
+ } catch (error) {
1732
+ // this.logMethodError('fetchAllergyItemRuleConfigs', error, {
1733
+ // strategyModelIds,
1734
+ // });
1735
+ throw error;
1736
+ }
1737
+ }
1738
+ async ensureAllergyItemRuleConfigsLoaded() {
1739
+ if (this.allergyItemRuleConfigs.length > 0) {
1740
+ return this.allergyItemRuleConfigs;
1741
+ }
1742
+ if (this.allergyItemRuleConfigsPromise) {
1743
+ return this.allergyItemRuleConfigsPromise;
1744
+ }
1745
+ this.allergyItemRuleConfigsPromise = (async () => {
1746
+ const staticConfigs = normalizeItemRuleStrategyConfigs(this.otherParams?.itemRule?.strategyConfigs);
1747
+ if (staticConfigs.length > 0) {
1748
+ this.allergyItemRuleConfigs = staticConfigs;
1749
+ this.allergyItemRuleEvaluator.setStrategyConfigs(staticConfigs);
1750
+ return staticConfigs;
1751
+ }
1752
+ const openData = await this.getOpenData();
1753
+ const enabled = toConfiguredBoolean(openData?.['sale.enable_item_rules']);
1754
+ const strategyModelIds = normalizePositiveIntegerList(openData?.['sale.enabled_item_rules']);
1755
+ if (!enabled || !strategyModelIds.length) {
1756
+ this.allergyItemRuleEvaluator.setStrategyConfigs([]);
1757
+ return [];
1758
+ }
1759
+ const loaded = await this.fetchAllergyItemRuleConfigs(strategyModelIds);
1760
+ this.allergyItemRuleConfigs = loaded;
1761
+ this.allergyItemRuleEvaluator.setStrategyConfigs(loaded);
1762
+ return loaded;
1763
+ })();
1764
+ return this.allergyItemRuleConfigsPromise;
1765
+ }
1766
+
1767
+ /**
1768
+ * 评估当前渠道与商品是否命中 ALLERGY_CHECK。
1769
+ * 只返回优先级最高 Action 中当前商品 Target 的 dataSource,不跨商品或 Action 合并。
1770
+ */
1771
+ async resolveProductAllergyRequirement(params) {
1772
+ const productId = normalizePositiveInteger(params.productId);
1773
+ if (!productId) return null;
1774
+ const strategyConfigs = await this.ensureAllergyItemRuleConfigsLoaded();
1775
+ if (!strategyConfigs.length) return null;
1776
+ const productVariantId = normalizePositiveInteger(params.productVariantId);
1777
+ const runtimeConfig = this.otherParams?.itemRule || {};
1778
+ const runtimePax = runtimeConfig.pax || {};
1779
+ const cartItems = (this.getOrderProducts?.() || []).map(item => ({
1780
+ product_id: Number(item?.product_id ?? item?.id),
1781
+ product_variant_id: Number(item?.product_variant_id ?? 0) || undefined,
1782
+ quantity: Number(item?.num ?? item?.quantity ?? 0)
1783
+ })).filter(item => Number.isFinite(item.product_id) && item.product_id > 0 && Number.isFinite(item.quantity) && item.quantity > 0);
1784
+ return this.allergyItemRuleEvaluator.getAllergyRequirement({
1785
+ pax: {
1786
+ total: Number(runtimePax.total ?? 0) || 0,
1787
+ adult: Number(runtimePax.adult ?? 0) || 0,
1788
+ child: Number(runtimePax.child ?? 0) || 0
1789
+ },
1790
+ cartItems,
1791
+ serviceType: String(runtimeConfig.serviceType ?? this.otherParams?.serviceType ?? ''),
1792
+ channel: String(params.channel ?? this.otherParams?.channel ?? this.otherParams?.platform ?? ''),
1793
+ currentProduct: {
1794
+ product_id: productId,
1795
+ ...(productVariantId ? {
1796
+ product_variant_id: productVariantId
1797
+ } : {})
1798
+ },
1799
+ submissionIndex: Number(runtimeConfig.submissionIndex ?? 0) || 0,
1800
+ strategyConfigs,
1801
+ custom: runtimeConfig.custom
1802
+ });
1803
+ }
1804
+
1805
+ /**
1806
+ * Load a resource policy without depending on the legacy appointmentBooking solution.
1807
+ */
1808
+ async getProtocol(policyId) {
1809
+ const normalizedPolicyId = String(policyId ?? '').trim();
1810
+ if (!normalizedPolicyId) {
1811
+ throw new Error('[UnifiedBookingSales] getProtocol 需要 policyId');
1812
+ }
1813
+ let policyUrl = 'shop-policy';
1814
+ if (this.otherParams.platform === 'kiosk' || this.otherParams.platform === 'pos' || this.otherParams.platform === 'shop') {
1815
+ policyUrl = 'policy';
1816
+ }
1817
+ return this.request.get(`/${policyUrl}/${encodeURIComponent(normalizedPolicyId)}`);
1818
+ }
678
1819
 
679
1820
  /**
680
1821
  * Loads the product catalog used by both UI and Planner. Schedule data is requested with each
@@ -696,6 +1837,10 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
696
1837
  await this.core.effects.emit(`${this.name}:onProductsLoaded`, result);
697
1838
  return result;
698
1839
  }
1840
+ async queryProducts(params = {}) {
1841
+ if (!this.store.products) throw new Error('[UnifiedBookingSales] products 模块未初始化');
1842
+ return this.store.products.queryProducts(params);
1843
+ }
699
1844
 
700
1845
  /** Date-first entry point. It delegates protocol details and cache ownership to ScheduleModule. */
701
1846
  async loadScheduleAvailableDate(params) {
@@ -863,23 +2008,17 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
863
2008
  preferredDiscountFound: preferredDiscountId === undefined || preferredDiscountId === null ? undefined : Boolean(preferredDiscount)
864
2009
  });
865
2010
  }
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
2011
  buildUnifiedOrderProduct(sourceProduct, ownershipKey, product) {
2012
+ const normalizedProductRaw = product?.raw || {};
2013
+ const quotationShelfId = sourceProduct._extend?.quotation_shelf_id ?? sourceProduct.quotation_shelf_id ?? sourceProduct.metadata?.quotation_shelf_id;
875
2014
  const orderProduct = this.transformBaseProductToOrderProduct({
876
2015
  payload: {
877
2016
  ...sourceProduct,
878
2017
  id: sourceProduct.id ?? product?.id,
879
2018
  product_id: sourceProduct.product_id ?? sourceProduct.id ?? product?.id,
880
2019
  quantity: sourceProduct.quantity ?? sourceProduct.num ?? product?.quantity ?? 1,
881
- price: sourceProduct.price ?? product?.price,
882
- selling_price: sourceProduct.selling_price ?? sourceProduct.price ?? product?.price,
2020
+ price: sourceProduct.price ?? normalizedProductRaw.price,
2021
+ selling_price: sourceProduct.selling_price ?? sourceProduct.price ?? normalizedProductRaw.selling_price ?? normalizedProductRaw.price,
883
2022
  metadata: {
884
2023
  ...(sourceProduct.metadata || {}),
885
2024
  [ownershipKey]: true
@@ -888,11 +2027,14 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
888
2027
  sourceProduct
889
2028
  });
890
2029
  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.
2030
+ // BaseSales may rebuild metadata from its price source. Stamp ownership and the selected
2031
+ // quotation shelf after that transform so neither booking adds nor edits lose quote identity.
893
2032
  orderProduct.metadata = {
894
2033
  ...(orderProduct.metadata || {}),
895
- [ownershipKey]: true
2034
+ [ownershipKey]: true,
2035
+ ...(quotationShelfId !== undefined ? {
2036
+ quotation_shelf_id: quotationShelfId
2037
+ } : {})
896
2038
  };
897
2039
  }
898
2040
  return orderProduct;
@@ -902,7 +2044,7 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
902
2044
  async removeExistingStagedLines(ownershipKey) {
903
2045
  const existingLines = (this.getTempOrder()?.products || []).filter(product => product?.metadata?.[ownershipKey] === true);
904
2046
  if (existingLines.length > 0) {
905
- await this.removeProductsFromOrder(existingLines.map(buildPlannerLineIdentity));
2047
+ await this.removeProductsFromOrder(existingLines.map(buildAvailabilityLineIdentity));
906
2048
  }
907
2049
  }
908
2050
  async stageProducts(params, ownershipKey) {
@@ -930,14 +2072,6 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
930
2072
  return this.getTempOrder();
931
2073
  }
932
2074
 
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
2075
  /**
942
2076
  * Adds ordinary retail products without asking ResourcePlanner for booking facts.
943
2077
  * Its staged lines have separate ownership so planner and retail skins can coexist safely.
@@ -980,59 +2114,194 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
980
2114
  }
981
2115
  return this.addProductToOrder(orderProduct);
982
2116
  }
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
2117
+ normalizeAvailabilityDateRange(params, timezone) {
2118
+ const today = (0, _localClock.formatLocalDate)((0, _localClock.localDateToScalar)(new Date()));
2119
+ const startDate = params.dateRange?.startDate || today;
2120
+ const endDate = params.dateRange?.endDate || (0, _localClock.formatLocalDate)((0, _localClock.addLocalCalendarDays)((0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(startDate)), 31));
2121
+ let startScalar;
2122
+ let endScalar;
2123
+ try {
2124
+ startScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(startDate));
2125
+ endScalar = (0, _localClock.localDateToScalarStart)((0, _localClock.parseLocalDate)(endDate));
2126
+ } catch (error) {
2127
+ throw new _ResourcePlanner.AvailabilityError('INVALID_DATE_RANGE', '日期范围必须是有效的 YYYY-MM-DD 且结束日期不早于开始日期', {
2128
+ dateRange: {
2129
+ startDate,
2130
+ endDate
2131
+ },
2132
+ timezone,
2133
+ cause: error instanceof Error ? error.message : String(error)
2134
+ });
2135
+ }
2136
+ if (endScalar < startScalar) {
2137
+ throw new _ResourcePlanner.AvailabilityError('INVALID_DATE_RANGE', '日期范围必须是有效的 YYYY-MM-DD 且结束日期不早于开始日期', {
2138
+ dateRange: {
2139
+ startDate,
2140
+ endDate
2141
+ },
2142
+ timezone
996
2143
  });
997
2144
  }
998
- return (this.getProductCatalog?.().products || []).map(normalizeProductForPlanner);
2145
+ const inclusiveDays = Math.trunc((endScalar - startScalar) / (24 * 60 * 60 * 1000)) + 1;
2146
+ if (inclusiveDays > MAX_AVAILABILITY_RANGE_DAYS) {
2147
+ throw new _ResourcePlanner.AvailabilityError('RANGE_TOO_LARGE', '日期范围最多包含 90 个自然日', {
2148
+ dateRange: {
2149
+ startDate,
2150
+ endDate
2151
+ },
2152
+ inclusiveDays
2153
+ });
2154
+ }
2155
+ return {
2156
+ startDate,
2157
+ endDate
2158
+ };
999
2159
  }
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();
2160
+ assertAvailabilityPrepareParams(params) {
2161
+ assertAvailabilityCatalogProductIds(params.productIds, 'prepare.productIds');
2162
+ const hasProductAnchor = Array.isArray(params.productIds) && params.productIds.length > 0;
2163
+ const hasResourceAnchor = Array.isArray(params.resourceIds) && params.resourceIds.length > 0;
2164
+ const hasDateAnchor = Boolean(params.dateRange?.startDate || params.dateRange?.endDate);
2165
+ if (!hasProductAnchor && !hasResourceAnchor && !hasDateAnchor) {
2166
+ throw new _ResourcePlanner.AvailabilityError('MISSING_ANCHOR', 'prepare 至少需要 productIds、resourceIds 或 dateRange 之一', {
2167
+ params
2168
+ });
2169
+ }
2170
+ if (!hasProductAnchor && !params.catalogScope) {
2171
+ throw new _ResourcePlanner.AvailabilityError('MISSING_CATALOG_SCOPE', '缺少 productIds 时必须提供 catalogScope', {
2172
+ params
2173
+ });
2174
+ }
2175
+ if (params.slotStepMinutes !== undefined) {
2176
+ const step = Number(params.slotStepMinutes);
2177
+ if (!Number.isInteger(step) || step <= 0) {
2178
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'slotStepMinutes 必须是正整数', {
2179
+ slotStepMinutes: params.slotStepMinutes
2180
+ });
2181
+ }
1007
2182
  }
1008
- return this.store.schedule.getScheduleList?.() || [];
2183
+ }
2184
+ captureAvailabilityScheduleCatalog() {
2185
+ if (!this.store.schedule) {
2186
+ throw new Error('[UnifiedBookingSales] schedule 模块未初始化');
2187
+ }
2188
+ this.availabilityScheduleCatalog = (0, _lodashEs.cloneDeep)(this.store.schedule.getScheduleList?.() || []);
1009
2189
  }
1010
2190
 
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
2191
+ /** Returns the solution-lifetime schedule snapshot captured after initialization. */
2192
+ getAvailabilityScheduleCatalog() {
2193
+ if (this.availabilityScheduleCatalog === null) {
2194
+ throw new Error('[UnifiedBookingSales] schedule 快照未初始化');
2195
+ }
2196
+ return this.availabilityScheduleCatalog;
2197
+ }
2198
+ buildAvailabilityOperatingHoursSnapshot(params) {
2199
+ const descriptor = params.prepare._availabilityOperatingHours || {
2200
+ source: params.prepare.businessHours ? 'prepare_business_hours' : 'default_business_hours',
2201
+ scheduleIds: []
2202
+ };
2203
+ if (descriptor.source !== 'open_data_schedule') {
2204
+ const windows = dateRangeDays(params.dateRange).flatMap(date => {
2205
+ const range = normalizeAvailabilityLocalDateTimeRange({
2206
+ startAt: `${date}T${params.businessHours.startTime}:00`,
2207
+ endAt: `${date}T${params.businessHours.endTime}:00`,
2208
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2209
+ rollOverEnd: true
2210
+ });
2211
+ return range ? [{
2212
+ startAt: range.startAt,
2213
+ endAt: range.endAt,
2214
+ source: descriptor.source
2215
+ }] : [];
2216
+ });
2217
+ return {
2218
+ source: descriptor.source,
2219
+ scheduleIds: [],
2220
+ missingScheduleIds: [],
2221
+ windows
2222
+ };
2223
+ }
2224
+ const scheduleMap = new Map(params.scheduleList.map(schedule => [String(schedule.id), schedule]));
2225
+ const schedules = descriptor.scheduleIds.map(scheduleId => scheduleMap.get(String(scheduleId))).filter(schedule => Boolean(schedule));
2226
+ const matchedScheduleIds = new Set(schedules.map(schedule => String(schedule.id)));
2227
+ const missingScheduleIds = descriptor.scheduleIds.filter(scheduleId => !matchedScheduleIds.has(String(scheduleId)));
2228
+ const windows = dateRangeDays(params.dateRange).flatMap(date => (0, _getDateIsInSchedule.getScheduleStartEndTimePoints)(date, schedules).flatMap(slot => {
2229
+ const range = normalizeAvailabilityLocalDateTimeRange({
2230
+ startAt: slot.start_at,
2231
+ endAt: slot.end_at,
2232
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2233
+ rollOverEnd: true
2234
+ });
2235
+ return range ? [{
2236
+ startAt: range.startAt,
2237
+ endAt: range.endAt,
2238
+ source: `operating_schedule:${descriptor.scheduleIds.map(String).join(',')}`
2239
+ }] : [];
2240
+ }));
2241
+ return {
2242
+ source: descriptor.source,
2243
+ scheduleIds: [...descriptor.scheduleIds],
2244
+ missingScheduleIds,
2245
+ windows
2246
+ };
2247
+ }
2248
+ async loadAvailabilityProducts(params) {
2249
+ const {
2250
+ prepare,
2251
+ dateRange
2252
+ } = params;
2253
+ const explicitProductIds = prepare.productIds || [];
2254
+ let rawProducts = [];
2255
+ if (Array.isArray(prepare.rawProducts)) {
2256
+ rawProducts = prepare.rawProducts;
2257
+ this.unifiedProductCatalog = [...rawProducts];
2258
+ } else if (explicitProductIds.length > 0) {
2259
+ rawProducts = await this.loadProducts({
2260
+ product_ids: explicitProductIds,
2261
+ schedule_date: dateRange.startDate
2262
+ });
2263
+ } else if (prepare.catalogScope?.type === 'schedule') {
2264
+ await this.loadScheduleAvailableDate({
2265
+ startDate: dateRange.startDate,
2266
+ endDate: dateRange.endDate,
2267
+ custom_page_id: Number(prepare.catalogScope.customPageId) || undefined,
2268
+ channel: prepare.catalogScope.channel
2269
+ });
2270
+ const dateSet = new Set(dateRangeDays(dateRange));
2271
+ const scheduleProductIds = (this.store.schedule.getAvailabilityScheduleDateList?.() || []).filter(item => dateSet.has(item.date)).flatMap(item => item.product_ids || []);
2272
+ const productIds = uniqueAvailabilityIds([...scheduleProductIds, ...(this.store.schedule.getOtherProductsIds?.() || [])]);
2273
+ if (productIds.length > 0) {
2274
+ assertAvailabilityCatalogProductIds(productIds, 'prepare.catalogScope.scheduleProductIds');
2275
+ rawProducts = await this.loadProducts({
2276
+ product_ids: productIds,
2277
+ schedule_date: dateRange.startDate
2278
+ });
2279
+ } else {
2280
+ this.unifiedProductCatalog = [];
2281
+ }
2282
+ } else if (prepare.catalogScope?.type === 'productList') {
2283
+ rawProducts = await this.loadProducts({
2284
+ ...prepare.catalogScope.params,
2285
+ schedule_date: prepare.catalogScope.params.schedule_date || dateRange.startDate
1021
2286
  });
1022
- scheduleList = this.store.schedule.getScheduleList?.() || [];
1023
2287
  }
1024
- return scheduleList;
2288
+ const normalized = rawProducts.map(product => normalizeProductForAvailability(product, params.scheduleList, this.getAvailabilityRuntimeOffsetMinutes()));
2289
+ if (!explicitProductIds.length && prepare.resourceIds?.length) {
2290
+ return normalized.filter(product => product.requirementGroups?.some(group => group.resourceIds?.some(resourceId => prepare.resourceIds.some(id => sameAvailabilityId(id, resourceId)))));
2291
+ }
2292
+ const productIdSet = explicitProductIds.length ? new Set(explicitProductIds.map(String)) : null;
2293
+ return productIdSet ? normalized.filter(product => productIdSet.has(String(product.id))) : normalized;
1025
2294
  }
1026
2295
 
1027
2296
  /**
1028
2297
  * Fetches resource metadata plus flat occupied events. Work windows are intentionally derived
1029
- * locally in buildPlannerResourcesFromV2 from /schedule, rather than requested from the legacy
2298
+ * locally in buildAvailabilityResourcesFromV2 from /schedule, rather than requested from the legacy
1030
2299
  * resource dates endpoint.
1031
2300
  */
1032
- async fetchPlannerResourcesV2(params) {
2301
+ async fetchAvailabilityResourcesV2(params) {
1033
2302
  const response = await this.request.get('/schedule/resource/list/v2', {
1034
- start_date: params.dateRange.start,
1035
- end_date: params.dateRange.end,
2303
+ start_date: params.dateRange.startDate,
2304
+ end_date: params.dateRange.endDate,
1036
2305
  resource_ids: params.resourceIds,
1037
2306
  front_end_cache_id: false
1038
2307
  }, {
@@ -1043,180 +2312,1081 @@ class UnifiedBookingSalesImpl extends _BookingTicket.BookingTicket {
1043
2312
  if (Array.isArray(data?.list)) return data.list;
1044
2313
  return [];
1045
2314
  }
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
- };
2315
+ async fetchAndPublishAvailabilityResourcesV2(params) {
2316
+ const normalizedResourceIds = this.normalizeAvailabilityIdList(params.resourceIds) || [];
2317
+ if (normalizedResourceIds.length === 0) return [];
2318
+ const scopeKey = this.buildAvailabilityRemoteResourceScopeKey(params.timezone);
2319
+ const cacheParams = {
2320
+ scopeKey,
2321
+ resourceIds: normalizedResourceIds,
2322
+ dateRange: params.dateRange
2323
+ };
2324
+ const cachedRawResources = this.availabilityRemoteOccupancyCache.read(cacheParams);
2325
+ if (cachedRawResources) {
2326
+ if (params.useCacheOnHit) {
2327
+ void this.refreshAvailabilityRemoteResourceQuery({
2328
+ timezone: params.timezone,
2329
+ dateRange: params.dateRange,
2330
+ resourceIds: normalizedResourceIds
2331
+ }).catch(error => {
2332
+ console.warn('[UnifiedBookingSales] Remote occupancy background refresh failed', error);
2333
+ });
2334
+ }
2335
+ return cachedRawResources;
2336
+ }
2337
+ return await this.refreshAvailabilityRemoteResourceQuery({
2338
+ timezone: params.timezone,
2339
+ dateRange: params.dateRange,
2340
+ resourceIds: normalizedResourceIds
2341
+ });
2342
+ }
2343
+ async refreshAvailabilityRemoteResourceQuery(params) {
2344
+ const normalizedResourceIds = this.normalizeAvailabilityIdList(params.resourceIds) || [];
2345
+ if (normalizedResourceIds.length === 0) return [];
2346
+ const queryKey = this.buildAvailabilityRemoteResourceQueryKey({
2347
+ timezone: params.timezone,
2348
+ dateRange: params.dateRange,
2349
+ resourceIds: normalizedResourceIds
2350
+ });
2351
+ const epoch = this.availabilityRemoteResourceEpoch;
2352
+ const existing = this.availabilityRemoteResourceQueryInFlight.get(queryKey);
2353
+ if (existing?.epoch === epoch) {
2354
+ return await existing.promise;
2355
+ }
2356
+ this.availabilityRemoteResourceQuerySequence += 1;
2357
+ const writeVersion = this.availabilityRemoteResourceQuerySequence;
2358
+ const promise = this.fetchAvailabilityResourcesV2({
2359
+ dateRange: params.dateRange,
2360
+ resourceIds: normalizedResourceIds
2361
+ });
2362
+ this.availabilityRemoteResourceQueryInFlight.set(queryKey, {
2363
+ epoch,
2364
+ promise
2365
+ });
2366
+ try {
2367
+ const rawResources = await promise;
2368
+ if (epoch === this.availabilityRemoteResourceEpoch) {
2369
+ this.availabilityRemoteOccupancyCache.publish({
2370
+ scopeKey: this.buildAvailabilityRemoteResourceScopeKey(params.timezone),
2371
+ timezoneName: params.timezone,
2372
+ resourceIds: normalizedResourceIds,
2373
+ dateRange: params.dateRange,
2374
+ rawResources,
2375
+ version: writeVersion
2376
+ });
2377
+ }
2378
+ return rawResources;
2379
+ } finally {
2380
+ const current = this.availabilityRemoteResourceQueryInFlight.get(queryKey);
2381
+ if (current?.promise === promise) {
2382
+ this.availabilityRemoteResourceQueryInFlight.delete(queryKey);
2383
+ }
1057
2384
  }
1058
- if (params.rawResources?.length) {
2385
+ }
2386
+ async loadAvailabilityResources(params) {
2387
+ const resourceIds = collectAvailabilityResourceIds(params.products, params.resourceIds || []);
2388
+ if (resourceIds.length === 0) {
1059
2389
  return {
1060
- resources: params.rawResources.map(normalizeResource).filter(resource => resource !== null),
1061
- externalEvents: params.context?.externalEvents || []
2390
+ resources: [],
2391
+ resourceOccupancies: []
1062
2392
  };
1063
2393
  }
1064
- const resourceIds = collectPlannerResourceIds(products, params.resourceIds || []);
1065
- if (!params.useResourceDateApi || !params.dateRange || resourceIds.length === 0) {
1066
- return {
2394
+ if (Array.isArray(params.rawResources)) {
2395
+ return buildAvailabilityResourcesFromV2({
2396
+ rawResources: params.rawResources,
2397
+ scheduleList: params.scheduleList,
2398
+ dateRange: params.dateRange,
2399
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
2400
+ });
2401
+ }
2402
+ if (params.fetchPolicy === 'cache_only') {
2403
+ const cachedRawResources = this.availabilityRemoteOccupancyCache.read({
2404
+ scopeKey: this.buildAvailabilityRemoteResourceScopeKey(params.timezone),
2405
+ resourceIds,
2406
+ dateRange: params.dateRange
2407
+ });
2408
+ if (cachedRawResources) {
2409
+ return buildAvailabilityResourcesFromV2({
2410
+ rawResources: cachedRawResources,
2411
+ scheduleList: params.scheduleList,
2412
+ dateRange: params.dateRange,
2413
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
2414
+ });
2415
+ }
2416
+ return params.fallbackResources ? (0, _lodashEs.cloneDeep)(params.fallbackResources) : {
1067
2417
  resources: [],
1068
- externalEvents: params.context?.externalEvents || []
2418
+ resourceOccupancies: []
1069
2419
  };
1070
2420
  }
1071
- const rawResources = await this.fetchPlannerResourcesV2({
2421
+ const rawResources = await this.fetchAndPublishAvailabilityResourcesV2({
2422
+ timezone: params.timezone,
1072
2423
  dateRange: params.dateRange,
1073
- resourceIds
2424
+ resourceIds,
2425
+ useCacheOnHit: true
1074
2426
  });
1075
- const scheduleList = await this.ensurePlannerSchedulesForResources(rawResources);
1076
- const materialized = buildPlannerResourcesFromV2({
2427
+ return buildAvailabilityResourcesFromV2({
1077
2428
  rawResources,
1078
- scheduleList,
1079
- dateRange: params.dateRange
2429
+ scheduleList: params.scheduleList,
2430
+ dateRange: params.dateRange,
2431
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes()
2432
+ });
2433
+ }
2434
+ resolveEditingOccupancyExclusions(tempOrder, productLineUids = []) {
2435
+ const productUids = new Set();
2436
+ const bookingUids = new Set();
2437
+ const scheduleEventIds = new Set();
2438
+ if (!productLineUids.length) return {
2439
+ productUids,
2440
+ bookingUids,
2441
+ scheduleEventIds
2442
+ };
2443
+ const products = Array.isArray(tempOrder?.products) ? tempOrder.products : [];
2444
+ const bookings = Array.isArray(tempOrder?.bookings) ? tempOrder.bookings : [];
2445
+ productLineUids.forEach(requestedUid => {
2446
+ const product = products.find(line => getProductLineUid(line) === String(requestedUid));
2447
+ if (!product) {
2448
+ throw new _ResourcePlanner.AvailabilityError('SELF_EXCLUSION_RESOLVE_FAILED', '无法解析编辑商品行,不能安全排除自身占用', {
2449
+ productLineUid: requestedUid
2450
+ });
2451
+ }
2452
+ const productUid = getProductLineUid(product);
2453
+ const linkedBookingUid = String(product.booking_uid ?? product.metadata?.booking_uid ?? '');
2454
+ const linkedBookings = bookings.filter(booking => linkedBookingUid && getBookingUid(booking) === linkedBookingUid || productUid && String(booking.product_uid ?? '') === productUid);
2455
+ if (!productUid || !linkedBookings.length) {
2456
+ throw new _ResourcePlanner.AvailabilityError('SELF_EXCLUSION_RESOLVE_FAILED', '编辑商品行缺少可验证的 booking 关联', {
2457
+ productLineUid: requestedUid,
2458
+ linkedBookingUid
2459
+ });
2460
+ }
2461
+ productUids.add(productUid);
2462
+ linkedBookings.forEach(booking => {
2463
+ const bookingUid = getBookingUid(booking);
2464
+ if (bookingUid) bookingUids.add(bookingUid);
2465
+ if (booking.schedule_event_id != null) {
2466
+ scheduleEventIds.add(String(booking.schedule_event_id));
2467
+ }
2468
+ });
1080
2469
  });
1081
2470
  return {
1082
- resources: materialized.resources,
1083
- externalEvents: [...(params.context?.externalEvents || []), ...materialized.externalEvents]
2471
+ productUids,
2472
+ bookingUids,
2473
+ scheduleEventIds
1084
2474
  };
1085
2475
  }
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 || {}),
2476
+ occupancyIsExcluded(occupancy, exclusions) {
2477
+ if (occupancy.scheduleEventId != null) {
2478
+ return exclusions.scheduleEventIds.has(String(occupancy.scheduleEventId));
2479
+ }
2480
+ if (occupancy.bookingUid != null) {
2481
+ return exclusions.bookingUids.has(String(occupancy.bookingUid));
2482
+ }
2483
+ if (occupancy.productUid != null) {
2484
+ return exclusions.productUids.has(String(occupancy.productUid));
2485
+ }
2486
+ return false;
2487
+ }
2488
+ collectCartResourceOccupancies(tempOrder, exclusions) {
2489
+ const bookings = Array.isArray(tempOrder?.bookings) ? tempOrder.bookings : [];
2490
+ return bookings.flatMap(booking => {
2491
+ const bookingUid = getBookingUid(booking);
2492
+ const productUid = String(booking.product_uid ?? booking.metadata?.product_uid ?? '');
2493
+ const scheduleEventId = toNumberId(booking.schedule_event_id);
2494
+ const startAt = booking.start_at || `${booking.start_date || ''} ${booking.start_time || ''}`.trim();
2495
+ const endAt = booking.end_at || `${booking.end_date || booking.start_date || ''} ${booking.end_time || ''}`.trim();
2496
+ const resources = Array.isArray(booking.resources) ? booking.resources : [];
2497
+ return resources.flatMap(resource => {
2498
+ const resourceId = resource.resource_id ?? resource.relation_id ?? resource.id;
2499
+ if (resourceId == null || !startAt || !endAt) return [];
2500
+ const range = normalizeAvailabilityLocalDateTimeRange({
2501
+ startAt,
2502
+ endAt,
2503
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2504
+ rollOverEnd: false
2505
+ });
2506
+ if (!range) return [];
2507
+ const occupancy = {
2508
+ resourceId,
2509
+ startAt: range.startAt,
2510
+ endAt: range.endAt,
2511
+ pax: Math.max(1, Number(resource.capacity ?? booking.capacity ?? 1) || 1),
2512
+ source: 'cart',
2513
+ scheduleEventId,
2514
+ bookingUid: bookingUid || undefined,
2515
+ productUid: productUid || undefined,
2516
+ raw: {
2517
+ booking,
2518
+ resource
2519
+ }
2520
+ };
2521
+ return this.occupancyIsExcluded(occupancy, exclusions) ? [] : [occupancy];
2522
+ });
2523
+ });
2524
+ }
2525
+ getCurrentOrderRevision() {
2526
+ return resolveOrderRevision(this.getTempOrder());
2527
+ }
2528
+ getAvailabilityContextEntry(contextId) {
2529
+ const context = this.availabilityContextRegistry.get(contextId);
2530
+ if (!context) {
2531
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'availability context 不存在或已失效,请重新 prepare', {
2532
+ contextId,
2533
+ lifecycleGeneration: this.availabilityLifecycleGeneration
2534
+ });
2535
+ }
2536
+ this.touchAvailabilityContext(context);
2537
+ return context;
2538
+ }
2539
+ getAvailabilityProjectionByKey(projectionKey) {
2540
+ if (!projectionKey) return null;
2541
+ return this.availabilityProjectionPool.get(projectionKey)?.projection || null;
2542
+ }
2543
+ getActiveAvailabilityProjectionEntry(context) {
2544
+ if (!context.activeProjectionKey) return null;
2545
+ const entry = this.availabilityProjectionPool.get(context.activeProjectionKey);
2546
+ if (!entry) return null;
2547
+ return this.touchAvailabilityProjection(entry);
2548
+ }
2549
+ decorateAvailabilityCandidate(contextId, contextVersion, candidate) {
2550
+ return {
2551
+ ...candidate,
2552
+ availabilityContextId: contextId,
2553
+ availabilityContextVersion: contextVersion
2554
+ };
2555
+ }
2556
+ assertAvailabilityCandidateFreshness(context, candidate) {
2557
+ if (candidate.availabilityContextId !== context.contextId) {
2558
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选与 availability context 不匹配,请重新获取候选', {
2559
+ candidateContextId: candidate.availabilityContextId,
2560
+ contextId: context.contextId
2561
+ });
2562
+ }
2563
+ const currentContextVersion = context.activeVersion;
2564
+ if (candidate.availabilityContextVersion !== currentContextVersion) {
2565
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选已过期,请重新获取最新时间段', {
2566
+ contextId: context.contextId,
2567
+ candidateContextVersion: candidate.availabilityContextVersion,
2568
+ currentContextVersion,
2569
+ snapshotId: context.lastSnapshotId
2570
+ });
2571
+ }
2572
+ }
2573
+ assertContextualQueryFreshness(context, request) {
2574
+ if (request.target === 'time') {
2575
+ request.candidates.forEach(candidate => {
2576
+ this.assertAvailabilityCandidateFreshness(context, candidate);
2577
+ });
2578
+ return;
2579
+ }
2580
+ if (request.target === 'resource') {
2581
+ request.selectedRanges.forEach(candidate => {
2582
+ this.assertAvailabilityCandidateFreshness(context, candidate);
2583
+ });
2584
+ }
2585
+ }
2586
+ storeAvailabilityProjectionEntry(params) {
2587
+ const previousContext = this.availabilityContextRegistry.get(params.contextId);
2588
+ const contextVersion = previousContext ? previousContext.activeVersion + (params.incrementVersion ? 1 : 0) : 1;
2589
+ const projectionKey = this.buildAvailabilityProjectionKey(params.contextId, contextVersion);
2590
+ const entry = {
2591
+ contextId: params.contextId,
2592
+ contextVersion,
2593
+ projectionKey,
2594
+ projection: params.projection,
2595
+ orderRevision: params.orderRevision,
2596
+ preparedAt: params.projection.meta.preparedAt,
2597
+ snapshotId: params.projection.meta.snapshotId
2598
+ };
2599
+ if (previousContext?.activeProjectionKey && previousContext.activeProjectionKey !== projectionKey) {
2600
+ this.removeAvailabilityProjection(previousContext.activeProjectionKey);
2601
+ }
2602
+ this.availabilityProjectionPool.set(projectionKey, entry);
2603
+ this.evictAvailabilityProjectionPool();
2604
+ const nextContext = {
2605
+ contextId: params.contextId,
2606
+ prepareParams: this.cloneAvailabilityPrepareParams(params.prepareParams),
2607
+ prepareKey: params.prepareKey,
2608
+ activeVersion: contextVersion,
2609
+ activeProjectionKey: projectionKey,
2610
+ createdAt: previousContext?.createdAt ?? params.projection.meta.preparedAt,
2611
+ lastAccessedAt: new Date().toISOString(),
2612
+ lastKnownOrderRevision: params.orderRevision,
2613
+ lastPreparedAt: params.projection.meta.preparedAt,
2614
+ lastSnapshotId: params.projection.meta.snapshotId,
2615
+ durationCandidateAnchorAt: previousContext?.durationCandidateAnchorAt ?? this.getAvailabilityDurationCandidateAnchorAt(params.projection),
2616
+ refreshNonce: previousContext?.refreshNonce ?? 0,
2617
+ lifecycleGeneration: this.availabilityLifecycleGeneration
2618
+ };
2619
+ this.touchAvailabilityContext(nextContext);
2620
+ this.evictAvailabilityContextRegistry();
2621
+ return this.touchAvailabilityProjection(entry);
2622
+ }
2623
+ async buildAvailabilityProjectionSnapshot(params, options = {}) {
2624
+ const normalizedPrepareParams = this.normalizeAvailabilityPrepareDescriptor(params);
2625
+ const timezoneLabel = String(normalizedPrepareParams.timezone);
2626
+ const dateRange = normalizedPrepareParams.dateRange;
2627
+ const slotStepMinutes = normalizedPrepareParams.slotStepMinutes || DEFAULT_AVAILABILITY_SLOT_STEP_MINUTES;
2628
+ const businessHours = normalizedPrepareParams.businessHours || DEFAULT_AVAILABILITY_BUSINESS_HOURS;
2629
+ const mode = normalizedPrepareParams.mode || 'appointment';
2630
+ const scheduleList = this.getAvailabilityScheduleCatalog();
2631
+ const operatingHours = this.buildAvailabilityOperatingHoursSnapshot({
2632
+ prepare: normalizedPrepareParams,
2633
+ dateRange,
2634
+ scheduleList,
2635
+ businessHours
2636
+ });
2637
+ const products = await this.loadAvailabilityProducts({
2638
+ prepare: normalizedPrepareParams,
2639
+ dateRange,
2640
+ scheduleList
2641
+ });
2642
+ const loadedResources = await this.loadAvailabilityResources({
2643
+ timezone: timezoneLabel,
2644
+ dateRange,
2645
+ resourceIds: normalizedPrepareParams.resourceIds,
1096
2646
  products,
1097
- resources: plannerResources.resources,
1098
- externalEvents: plannerResources.externalEvents
2647
+ rawResources: normalizedPrepareParams.rawResources,
2648
+ scheduleList,
2649
+ fetchPolicy: options.resourceFetchPolicy || 'default',
2650
+ fallbackResources: options.fallbackResources
2651
+ });
2652
+ const tempOrder = this.getTempOrder();
2653
+ const exclusions = this.resolveEditingOccupancyExclusions(tempOrder, normalizedPrepareParams.editingProductLineUids);
2654
+ const injectedRawOccupancies = (normalizedPrepareParams.rawOccupancies || []).flatMap(occupancy => {
2655
+ const range = normalizeAvailabilityLocalDateTimeRange({
2656
+ startAt: occupancy.startAt,
2657
+ endAt: occupancy.endAt,
2658
+ fixedOffsetMinutes: this.getAvailabilityRuntimeOffsetMinutes(),
2659
+ rollOverEnd: false
2660
+ });
2661
+ if (!range) return [];
2662
+ return [{
2663
+ ...occupancy,
2664
+ startAt: range.startAt,
2665
+ endAt: range.endAt
2666
+ }];
2667
+ });
2668
+ const remoteOccupancies = [...loadedResources.resourceOccupancies, ...(loadedResources.includesInjectedRawOccupancies ? [] : injectedRawOccupancies)].filter(occupancy => !this.occupancyIsExcluded(occupancy, exclusions));
2669
+ const cartOccupancies = this.collectCartResourceOccupancies(tempOrder, exclusions);
2670
+ const preparedAt = this.getAvailabilityRuntimeDateTime();
2671
+ const durationCandidateAnchorAt = options.durationCandidateAnchorAt || preparedAt;
2672
+ this.availabilityProjectionSequence += 1;
2673
+ const orderRevision = this.getCurrentOrderRevision();
2674
+ const projection = (0, _ResourcePlanner.createAvailabilityProjection)({
2675
+ products,
2676
+ resources: loadedResources.resources,
2677
+ resourceOccupancies: [...remoteOccupancies, ...cartOccupancies],
2678
+ mode,
2679
+ timezone: timezoneLabel,
2680
+ coverage: dateRange,
2681
+ slotStepMinutes,
2682
+ businessHours,
2683
+ operatingHours,
2684
+ preparedAt,
2685
+ now: preparedAt,
2686
+ orderRevision,
2687
+ snapshotId: `${this.name}:${Date.now()}:${this.availabilityProjectionSequence}`,
2688
+ anchors: {
2689
+ productIds: normalizedPrepareParams.productIds,
2690
+ resourceIds: normalizedPrepareParams.resourceIds,
2691
+ dateRange
2692
+ },
2693
+ prepareParams: normalizedPrepareParams,
2694
+ durationCandidateAnchorAt
2695
+ });
2696
+ return {
2697
+ projection,
2698
+ prepareParams: normalizedPrepareParams,
2699
+ orderRevision
1099
2700
  };
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 || []
2701
+ }
2702
+ async getSharedPreparedAvailabilitySnapshot(params) {
2703
+ const lifecycleGeneration = this.availabilityLifecycleGeneration;
2704
+ const normalizedParams = this.normalizeAvailabilityPrepareDescriptor(params);
2705
+ const hasRawInputs = params.rawProducts !== undefined || params.rawResources !== undefined || params.rawOccupancies !== undefined;
2706
+ const prepareKey = hasRawInputs ? JSON.stringify(stableValue({
2707
+ scope: this.getAvailabilityScopeDescriptor(),
2708
+ prepare: {
2709
+ ...normalizedParams,
2710
+ rawProducts: undefined,
2711
+ rawResources: undefined,
2712
+ rawOccupancies: undefined
2713
+ },
2714
+ rawInputs: {
2715
+ products: params.rawProducts !== undefined,
2716
+ resources: params.rawResources !== undefined,
2717
+ occupancies: params.rawOccupancies !== undefined
2718
+ }
2719
+ })) : this.buildAvailabilityPrepareKey(normalizedParams);
2720
+ if (hasRawInputs) {
2721
+ const snapshot = await this.buildAvailabilityProjectionSnapshot(normalizedParams);
2722
+ if (lifecycleGeneration !== this.availabilityLifecycleGeneration) {
2723
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'prepare 期间业务作用域或实例生命周期已变化,请重新 prepare', {
2724
+ prepareKey,
2725
+ expectedLifecycleGeneration: lifecycleGeneration,
2726
+ currentLifecycleGeneration: this.availabilityLifecycleGeneration
2727
+ });
2728
+ }
2729
+ return {
2730
+ prepareKey,
2731
+ snapshot
2732
+ };
2733
+ }
2734
+ const existing = this.availabilityPrepareInFlight.get(prepareKey);
2735
+ const promise = existing?.lifecycleGeneration === lifecycleGeneration ? existing.promise : this.buildAvailabilityProjectionSnapshot(normalizedParams);
2736
+ if (!existing || existing.lifecycleGeneration !== lifecycleGeneration) {
2737
+ this.availabilityPrepareInFlight.set(prepareKey, {
2738
+ lifecycleGeneration,
2739
+ prepareKey,
2740
+ promise
2741
+ });
2742
+ }
2743
+ try {
2744
+ const snapshot = await promise;
2745
+ if (lifecycleGeneration !== this.availabilityLifecycleGeneration) {
2746
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'prepare 期间业务作用域或实例生命周期已变化,请重新 prepare', {
2747
+ prepareKey,
2748
+ expectedLifecycleGeneration: lifecycleGeneration,
2749
+ currentLifecycleGeneration: this.availabilityLifecycleGeneration
2750
+ });
2751
+ }
2752
+ return {
2753
+ prepareKey,
2754
+ snapshot
2755
+ };
2756
+ } finally {
2757
+ const current = this.availabilityPrepareInFlight.get(prepareKey);
2758
+ if (current?.promise === promise) {
2759
+ this.availabilityPrepareInFlight.delete(prepareKey);
2760
+ }
2761
+ }
2762
+ }
2763
+ async refreshAvailabilityContextProjection(params) {
2764
+ const existingInFlight = this.availabilityProjectionRefreshInFlight.get(params.contextId);
2765
+ if (existingInFlight) return await existingInFlight;
2766
+ const context = this.getAvailabilityContextEntry(params.contextId);
2767
+ const expectedVersion = context.activeVersion;
2768
+ const expectedRefreshNonce = context.refreshNonce;
2769
+ const expectedLifecycleGeneration = this.availabilityLifecycleGeneration;
2770
+ const refreshPromise = (async () => {
2771
+ const snapshot = await this.buildAvailabilityProjectionSnapshot(this.cloneAvailabilityPrepareParams(context.prepareParams), {
2772
+ durationCandidateAnchorAt: context.durationCandidateAnchorAt
2773
+ });
2774
+ const currentContext = this.availabilityContextRegistry.get(params.contextId);
2775
+ if (!currentContext) return null;
2776
+ if (expectedLifecycleGeneration !== this.availabilityLifecycleGeneration || currentContext.activeVersion !== expectedVersion || currentContext.refreshNonce !== expectedRefreshNonce || currentContext.lifecycleGeneration !== expectedLifecycleGeneration) {
2777
+ return null;
2778
+ }
2779
+ const projectionEntry = this.storeAvailabilityProjectionEntry({
2780
+ contextId: currentContext.contextId,
2781
+ prepareParams: snapshot.prepareParams,
2782
+ prepareKey: currentContext.prepareKey,
2783
+ projection: snapshot.projection,
2784
+ orderRevision: snapshot.orderRevision,
2785
+ incrementVersion: true
2786
+ });
2787
+ await this.emitAvailabilityDiagnostic({
2788
+ action: 'refresh',
2789
+ contextId: currentContext.contextId,
2790
+ contextVersion: projectionEntry.contextVersion,
2791
+ projection: projectionEntry.projection,
2792
+ snapshotId: projectionEntry.snapshotId,
2793
+ orderRevision: projectionEntry.orderRevision,
2794
+ previousOrderRevision: params.previousOrderRevision,
2795
+ reason: params.reason
2796
+ });
2797
+ return projectionEntry;
2798
+ })();
2799
+ this.availabilityProjectionRefreshInFlight.set(params.contextId, refreshPromise);
2800
+ try {
2801
+ return await refreshPromise;
2802
+ } finally {
2803
+ const current = this.availabilityProjectionRefreshInFlight.get(params.contextId);
2804
+ if (current === refreshPromise) {
2805
+ this.availabilityProjectionRefreshInFlight.delete(params.contextId);
2806
+ }
2807
+ }
2808
+ }
2809
+ async ensureAvailabilityContextProjection(contextId, reason) {
2810
+ let retries = 0;
2811
+ while (retries < 3) {
2812
+ const context = this.getAvailabilityContextEntry(contextId);
2813
+ const currentOrderRevision = this.getCurrentOrderRevision();
2814
+ const projectionEntry = this.getActiveAvailabilityProjectionEntry(context);
2815
+ if (projectionEntry && projectionEntry.orderRevision === currentOrderRevision) {
2816
+ return {
2817
+ context,
2818
+ projectionEntry,
2819
+ refreshed: false
2820
+ };
2821
+ }
2822
+ const refreshedProjectionEntry = await this.refreshAvailabilityContextProjection({
2823
+ contextId,
2824
+ reason: projectionEntry ? reason : `${reason}:projection_cache_miss`,
2825
+ previousOrderRevision: context.lastKnownOrderRevision
2826
+ });
2827
+ if (!refreshedProjectionEntry) {
2828
+ retries += 1;
2829
+ continue;
2830
+ }
2831
+ if (refreshedProjectionEntry.orderRevision !== this.getCurrentOrderRevision()) {
2832
+ retries += 1;
2833
+ continue;
2834
+ }
2835
+ return {
2836
+ context: this.getAvailabilityContextEntry(contextId),
2837
+ projectionEntry: refreshedProjectionEntry,
2838
+ refreshed: true
2839
+ };
2840
+ }
2841
+ throw new _ResourcePlanner.AvailabilityError('STALE_PROJECTION', 'availability projection 刷新期间购物车持续变化,请重新 prepare', {
2842
+ contextId,
2843
+ reason
1108
2844
  });
1109
- await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onPlannerContextLoaded}`, snapshot);
1110
- return this.getPlanner().getSnapshot();
1111
2845
  }
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);
2846
+ async tryAcquireAvailabilityCommitLock(contextId) {
2847
+ if (this.availabilityCommitLocks.has(contextId)) {
2848
+ const waitingContext = this.availabilityContextRegistry.get(contextId);
2849
+ await this.emitAvailabilityDiagnostic({
2850
+ action: 'commit_lock_wait',
2851
+ contextId,
2852
+ contextVersion: waitingContext?.activeVersion ?? null,
2853
+ projection: this.getAvailabilityProjectionByKey(waitingContext?.activeProjectionKey),
2854
+ snapshotId: waitingContext?.lastSnapshotId,
2855
+ orderRevision: waitingContext?.lastKnownOrderRevision,
2856
+ reason: 'AVAILABILITY_COMMIT_IN_PROGRESS'
2857
+ });
2858
+ throw this.createAvailabilityFacadeError('AVAILABILITY_COMMIT_IN_PROGRESS', '同一 availability context 已有 commit 正在进行中', {
2859
+ contextId
2860
+ });
2861
+ }
2862
+ this.availabilityCommitLocks.add(contextId);
2863
+ const currentContext = this.availabilityContextRegistry.get(contextId);
2864
+ await this.emitAvailabilityDiagnostic({
2865
+ action: 'commit_lock_acquired',
2866
+ contextId,
2867
+ contextVersion: currentContext?.activeVersion ?? null,
2868
+ projection: this.getAvailabilityProjectionByKey(currentContext?.activeProjectionKey),
2869
+ snapshotId: currentContext?.lastSnapshotId,
2870
+ orderRevision: currentContext?.lastKnownOrderRevision,
2871
+ reason: 'commit_start'
2872
+ });
2873
+ }
2874
+ async releaseAvailabilityCommitLock(contextId) {
2875
+ this.availabilityCommitLocks.delete(contextId);
2876
+ const releasedContext = this.availabilityContextRegistry.get(contextId);
2877
+ await this.emitAvailabilityDiagnostic({
2878
+ action: 'commit_lock_released',
2879
+ contextId,
2880
+ contextVersion: releasedContext?.activeVersion ?? null,
2881
+ projection: this.getAvailabilityProjectionByKey(releasedContext?.activeProjectionKey),
2882
+ snapshotId: releasedContext?.lastSnapshotId,
2883
+ orderRevision: releasedContext?.lastKnownOrderRevision,
2884
+ reason: 'commit_end'
2885
+ });
1116
2886
  }
1117
2887
 
1118
- /** Turns compatible availability ranges into user-selectable intervals and ready-to-commit assignments. */
1119
- resolveAssignableSlots(query = {}) {
1120
- return this.getPlanner().resolveAssignableSlots(query);
2888
+ /** IO boundary: loads catalog, schedules, resources, occupancies, and registers one context. */
2889
+ async prepareAvailabilityProjection(params) {
2890
+ this.assertAvailabilityPrepareParams(params);
2891
+ const paramsWithDefaults = await this.resolveAvailabilityPrepareDefaults(params);
2892
+ const {
2893
+ prepareKey,
2894
+ snapshot
2895
+ } = await this.getSharedPreparedAvailabilitySnapshot(paramsWithDefaults);
2896
+ const contextId = this.createAvailabilityContextId();
2897
+ const projectionEntry = this.storeAvailabilityProjectionEntry({
2898
+ contextId,
2899
+ prepareParams: snapshot.prepareParams,
2900
+ prepareKey,
2901
+ projection: snapshot.projection,
2902
+ orderRevision: snapshot.orderRevision,
2903
+ incrementVersion: false
2904
+ });
2905
+ const context = this.getAvailabilityContextEntry(contextId);
2906
+ const handle = this.createAvailabilityContextRef(context);
2907
+ const payload = {
2908
+ context: handle,
2909
+ contextVersion: context.activeVersion,
2910
+ projection: projectionEntry.projection,
2911
+ refreshed: false,
2912
+ reason: 'prepare'
2913
+ };
2914
+ await this.emitAvailabilityDiagnostic({
2915
+ action: 'prepare',
2916
+ contextId,
2917
+ contextVersion: context.activeVersion,
2918
+ projection: projectionEntry.projection,
2919
+ snapshotId: context.lastSnapshotId,
2920
+ orderRevision: context.lastKnownOrderRevision,
2921
+ reason: 'prepare'
2922
+ });
2923
+ await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilityProjectionPrepared}`, payload);
2924
+ return handle;
1121
2925
  }
1122
2926
 
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);
2927
+ /** Candidate calculation over the current context projection and runtime minute. */
2928
+ async getProductTimeRanges(contextId, request = {}) {
2929
+ const {
2930
+ context,
2931
+ projectionEntry
2932
+ } = await this.ensureAvailabilityContextProjection(contextId, 'get_product_time_ranges');
2933
+ const contextVersion = context.activeVersion;
2934
+ const allowOutsideOperatingHoursForRequestedStartTimes = String(this.otherParams?.platform ?? '').trim().toLowerCase() === 'pos' && Object.values(request.durationStartTimesByProductId || {}).some(startTimes => Array.isArray(startTimes) && startTimes.length > 0);
2935
+ return (0, _ResourcePlanner.getProductTimeRanges)(projectionEntry.projection, {
2936
+ ...request,
2937
+ ...(allowOutsideOperatingHoursForRequestedStartTimes ? {
2938
+ allowOutsideOperatingHoursForRequestedStartTimes: true
2939
+ } : {})
2940
+ }, {
2941
+ evaluatedAt: this.getAvailabilityRuntimeDateTime()
2942
+ }).map(group => ({
2943
+ ...group,
2944
+ ranges: group.ranges.map(candidate => this.decorateAvailabilityCandidate(context.contextId, contextVersion, candidate))
2945
+ }));
1126
2946
  }
1127
2947
 
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);
2948
+ /** Thin facade: auto-refresh on cart drift, then delegate to the pure projection query. */
2949
+ async queryBookingAvailability(contextId, request) {
2950
+ const {
2951
+ context,
2952
+ projectionEntry,
2953
+ refreshed
2954
+ } = await this.ensureAvailabilityContextProjection(contextId, 'query_booking_availability');
2955
+ this.assertContextualQueryFreshness(context, request);
2956
+ const result = (0, _ResourcePlanner.queryAvailabilityProjection)(projectionEntry.projection, request);
2957
+ const contextVersion = context.activeVersion;
2958
+ const contextualResult = result.target === 'time' ? {
2959
+ ...result,
2960
+ data: result.data.map(group => ({
2961
+ ...group,
2962
+ times: group.times.map(item => ({
2963
+ ...item,
2964
+ availabilityContextId: context.contextId,
2965
+ availabilityContextVersion: contextVersion
2966
+ }))
2967
+ }))
2968
+ } : result.target === 'resource' ? {
2969
+ ...result,
2970
+ data: result.data.map(group => ({
2971
+ ...group,
2972
+ ranges: group.ranges.map(rangeGroup => ({
2973
+ ...rangeGroup,
2974
+ range: {
2975
+ ...rangeGroup.range,
2976
+ availabilityContextId: context.contextId,
2977
+ availabilityContextVersion: contextVersion
2978
+ }
2979
+ }))
2980
+ }))
2981
+ } : result;
2982
+ return {
2983
+ ...contextualResult,
2984
+ contextId: context.contextId,
2985
+ contextVersion,
2986
+ refreshed
2987
+ };
2988
+ }
2989
+ releaseAvailabilityContext(contextId) {
2990
+ const context = this.availabilityContextRegistry.get(contextId);
2991
+ if (!context) return;
2992
+ this.removeAvailabilityContext(contextId);
2993
+ void this.emitAvailabilityDiagnostic({
2994
+ action: 'release',
2995
+ contextId,
2996
+ contextVersion: context.activeVersion,
2997
+ projection: this.getActiveAvailabilityProjectionEntry(context)?.projection || null,
2998
+ snapshotId: context.lastSnapshotId,
2999
+ orderRevision: context.lastKnownOrderRevision,
3000
+ reason: 'explicit_release'
3001
+ });
3002
+ }
3003
+ assertAvailabilityCommitWriteCAS(params) {
3004
+ const context = this.availabilityContextRegistry.get(params.contextId);
3005
+ if (!context) {
3006
+ throw this.createAvailabilityFacadeError('AVAILABILITY_CONTEXT_NOT_FOUND', 'availability context 不存在或已失效,请重新 prepare', {
3007
+ contextId: params.contextId
3008
+ });
1136
3009
  }
1137
- return this.getPlanner().getSnapshot();
3010
+ if (this.availabilityLifecycleGeneration !== params.expectedLifecycleGeneration || context.lifecycleGeneration !== params.expectedLifecycleGeneration || context.activeVersion !== params.expectedVersion || context.refreshNonce !== params.expectedRefreshNonce || this.getCurrentOrderRevision() !== params.expectedOrderRevision) {
3011
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已变化,当前选择已失效,请重新获取候选', {
3012
+ contextId: params.contextId,
3013
+ expectedVersion: params.expectedVersion,
3014
+ currentVersion: context.activeVersion,
3015
+ expectedRefreshNonce: params.expectedRefreshNonce,
3016
+ currentRefreshNonce: context.refreshNonce,
3017
+ expectedLifecycleGeneration: params.expectedLifecycleGeneration,
3018
+ currentLifecycleGeneration: this.availabilityLifecycleGeneration,
3019
+ expectedOrderRevision: params.expectedOrderRevision,
3020
+ currentOrderRevision: this.getCurrentOrderRevision()
3021
+ });
3022
+ }
3023
+ return context;
1138
3024
  }
1139
-
1140
- /** Rechecks remote events, capacity and conflicts among the current selection before committing. */
1141
- validatePlannerSelection() {
1142
- return this.getPlanner().validateSelection();
3025
+ findProductLineByUid(productLineUid) {
3026
+ return (this.getTempOrder()?.products || []).find(product => getProductLineUid(product) === String(productLineUid));
1143
3027
  }
1144
-
1145
- /** Read-only snapshot for UI diagnostics and progressive flow steps. */
1146
- getPlannerSnapshot() {
1147
- return this.getPlanner().getSnapshot();
3028
+ findLinkedBooking(product) {
3029
+ const productUid = getProductLineUid(product);
3030
+ const bookingUid = String(product.booking_uid ?? product.metadata?.booking_uid ?? '');
3031
+ return (this.getTempOrder()?.bookings || []).find(booking => bookingUid && getBookingUid(booking) === bookingUid || productUid && String(booking.product_uid ?? '') === productUid);
1148
3032
  }
1149
3033
 
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
3034
+ /** Revalidates against the latest landed occupancy cache, then mutates exactly one product line. */
3035
+ async commitAvailabilitySelection(contextId, params) {
3036
+ const normalizedContextId = String(contextId || '').trim();
3037
+ if (!normalizedContextId) {
3038
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'commitAvailabilitySelection 缺少 contextId', {
3039
+ candidate: params.candidate
1161
3040
  });
1162
3041
  }
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
- }]
3042
+ await this.tryAcquireAvailabilityCommitLock(normalizedContextId);
3043
+ try {
3044
+ const bookingCount = Number(params.bookingCount ?? 1);
3045
+ if (!Number.isInteger(bookingCount) || bookingCount <= 0) {
3046
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'bookingCount 必须是正整数', {
3047
+ bookingCount: params.bookingCount
3048
+ });
3049
+ }
3050
+ if (params.mode === 'edit' && bookingCount !== 1) {
3051
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', '编辑预约商品时 bookingCount 必须为 1', {
3052
+ bookingCount
3053
+ });
3054
+ }
3055
+ const orderProductQuantity = params.orderProduct?.num ?? params.orderProduct?.quantity ?? params.orderProduct?._extend?.quantity;
3056
+ if (orderProductQuantity !== undefined && orderProductQuantity !== null && Number(orderProductQuantity) !== bookingCount) {
3057
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'orderProduct 数量与 bookingCount 不一致', {
3058
+ bookingCount,
3059
+ orderProductQuantity
3060
+ });
3061
+ }
3062
+ const partySize = Number(params.partySize);
3063
+ if (!Number.isInteger(partySize) || partySize <= 0) {
3064
+ throw new _ResourcePlanner.AvailabilityError('INVALID_RESERVATION_QUANTITY', 'partySize 必须是正整数', {
3065
+ partySize: params.partySize
3066
+ });
3067
+ }
3068
+ const {
3069
+ context,
3070
+ projectionEntry,
3071
+ refreshed
3072
+ } = await this.ensureAvailabilityContextProjection(normalizedContextId, 'commit_precheck');
3073
+ if (refreshed) {
3074
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已刷新,请重新获取候选', {
3075
+ contextId: normalizedContextId,
3076
+ currentContextVersion: context.activeVersion,
3077
+ candidateContextVersion: params.candidate.availabilityContextVersion
3078
+ });
3079
+ }
3080
+ const boundEditingUids = context.prepareParams.editingProductLineUids || [];
3081
+ if (params.mode === 'edit') {
3082
+ if (!params.productLineUid || !boundEditingUids.includes(String(params.productLineUid))) {
3083
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'edit context 与当前商品行不匹配,请重新 prepare 编辑上下文', {
3084
+ contextId: normalizedContextId,
3085
+ productLineUid: params.productLineUid,
3086
+ editingProductLineUids: boundEditingUids
3087
+ });
1175
3088
  }
3089
+ } else if (boundEditingUids.length > 0) {
3090
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'edit context 不能用于 add,请重新 prepare 新增上下文', {
3091
+ contextId: normalizedContextId,
3092
+ editingProductLineUids: boundEditingUids
3093
+ });
3094
+ }
3095
+ if (projectionEntry.contextVersion !== context.activeVersion) {
3096
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', 'availability context 已刷新,请重新获取候选', {
3097
+ contextId: normalizedContextId,
3098
+ currentContextVersion: context.activeVersion,
3099
+ projectionContextVersion: projectionEntry.contextVersion
3100
+ });
3101
+ }
3102
+ if (projectionEntry.orderRevision !== this.getCurrentOrderRevision()) {
3103
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '购物车已变化,请重新获取候选', {
3104
+ contextId: normalizedContextId,
3105
+ currentContextVersion: context.activeVersion,
3106
+ projectionOrderRevision: projectionEntry.orderRevision,
3107
+ currentOrderRevision: this.getCurrentOrderRevision()
3108
+ });
3109
+ }
3110
+ if (projectionEntry.contextVersion !== params.candidate.availabilityContextVersion) {
3111
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '候选已过期,请重新获取最新时间段', {
3112
+ contextId: normalizedContextId,
3113
+ candidateContextVersion: params.candidate.availabilityContextVersion,
3114
+ currentContextVersion: context.activeVersion
3115
+ });
3116
+ }
3117
+ this.assertAvailabilityCandidateFreshness(context, params.candidate);
3118
+ const orderProductId = params.orderProduct?.product_id ?? params.orderProduct?.id;
3119
+ if (!sameAvailabilityId(orderProductId, params.candidate.productId)) {
3120
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'orderProduct 与 candidate 商品不一致', {
3121
+ orderProductId,
3122
+ candidateProductId: params.candidate.productId
3123
+ });
3124
+ }
3125
+ if (params.mode === 'edit' && !params.productLineUid) {
3126
+ throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', 'edit 模式必须提供 productLineUid');
3127
+ }
3128
+ const editingLine = params.mode === 'edit' ? this.findProductLineByUid(String(params.productLineUid)) : undefined;
3129
+ if (params.mode === 'edit' && !editingLine) {
3130
+ throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', '找不到要编辑的预约商品行', {
3131
+ productLineUid: params.productLineUid
3132
+ });
3133
+ }
3134
+ if (editingLine && !sameAvailabilityId(editingLine.product_id ?? editingLine.id, params.candidate.productId)) {
3135
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', '编辑商品行与 candidate 商品不一致', {
3136
+ productLineUid: params.productLineUid,
3137
+ productLineProductId: editingLine.product_id ?? editingLine.id,
3138
+ candidateProductId: params.candidate.productId
3139
+ });
3140
+ }
3141
+ if (!Array.isArray(params.requirementSelections)) {
3142
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'requirementSelections 必须是数组', {
3143
+ requirementSelections: params.requirementSelections
3144
+ });
3145
+ }
3146
+ const baseProjection = projectionEntry.projection;
3147
+ let commitWriteGuard = {
3148
+ contextId: normalizedContextId,
3149
+ expectedVersion: context.activeVersion,
3150
+ expectedRefreshNonce: context.refreshNonce,
3151
+ expectedLifecycleGeneration: this.availabilityLifecycleGeneration,
3152
+ expectedOrderRevision: String(baseProjection.meta.orderRevision ?? '')
1176
3153
  };
1177
- }
1178
- const validation = this.validatePlannerSelection();
1179
- if (!validation.ok) {
1180
- return {
1181
- tempOrder: this.getTempOrder(),
1182
- assignments,
1183
- validation
3154
+ const prepareParams = baseProjection.meta.prepareParams;
3155
+ if (!prepareParams) {
3156
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', 'projection 缺少 prepareParams,无法执行缓存重校验');
3157
+ }
3158
+ const fallbackResources = {
3159
+ resources: (0, _lodashEs.cloneDeep)([...(baseProjection.resources || [])]),
3160
+ resourceOccupancies: (0, _lodashEs.cloneDeep)([...(baseProjection.resourceOccupancies || [])].filter(occupancy => occupancy.source !== 'cart')),
3161
+ includesInjectedRawOccupancies: true
1184
3162
  };
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
3163
+ const commitSnapshot = await this.buildAvailabilityProjectionSnapshot({
3164
+ ...this.cloneAvailabilityPrepareParams(prepareParams),
3165
+ editingProductLineUids: params.mode === 'edit' && params.productLineUid ? [params.productLineUid] : []
3166
+ }, {
3167
+ resourceFetchPolicy: 'cache_only',
3168
+ fallbackResources,
3169
+ durationCandidateAnchorAt: this.getAvailabilityDurationCandidateAnchorAt(baseProjection) || context.durationCandidateAnchorAt
3170
+ });
3171
+ const refreshedProjection = commitSnapshot.projection;
3172
+ if (String(refreshedProjection.meta.orderRevision ?? '') !== String(baseProjection.meta.orderRevision ?? '')) {
3173
+ await this.refreshAvailabilityContextProjection({
3174
+ contextId: normalizedContextId,
3175
+ reason: 'commit_order_revision_changed',
3176
+ previousOrderRevision: String(baseProjection.meta.orderRevision ?? '')
3177
+ });
3178
+ const latestContext = this.getAvailabilityContextEntry(normalizedContextId);
3179
+ throw this.createAvailabilityFacadeError('AVAILABILITY_SELECTION_STALE', '最终缓存重校验期间购物车已变化,请重新获取候选', {
3180
+ contextId: normalizedContextId,
3181
+ candidateContextVersion: params.candidate.availabilityContextVersion,
3182
+ currentContextVersion: latestContext.activeVersion,
3183
+ projectionOrderRevision: baseProjection.meta.orderRevision,
3184
+ refreshedOrderRevision: refreshedProjection.meta.orderRevision,
3185
+ snapshotId: baseProjection.meta.snapshotId,
3186
+ refreshedSnapshotId: refreshedProjection.meta.snapshotId
3187
+ });
3188
+ }
3189
+ this.assertAvailabilityCommitWriteCAS(commitWriteGuard);
3190
+ const candidateDurationMinutes = getCandidateDurationMinutes(params.candidate);
3191
+ const candidateStartTime = getCandidateStartTime(params.candidate);
3192
+ const allowPosAvailabilityOverride = String(this.otherParams?.platform ?? '').trim().toLowerCase() === 'pos';
3193
+ const refreshedRanges = (0, _ResourcePlanner.getProductTimeRanges)(refreshedProjection, {
3194
+ productIds: [params.candidate.productId],
3195
+ dateRange: {
3196
+ startDate: params.candidate.startAt.slice(0, 10),
3197
+ endDate: params.candidate.startAt.slice(0, 10)
3198
+ },
3199
+ ...(candidateDurationMinutes ? {
3200
+ durationMinutesByProductId: {
3201
+ [String(params.candidate.productId)]: candidateDurationMinutes
3202
+ },
3203
+ ...(candidateStartTime ? {
3204
+ durationStartTimesByProductId: {
3205
+ [String(params.candidate.productId)]: [candidateStartTime]
3206
+ },
3207
+ ...(allowPosAvailabilityOverride ? {
3208
+ allowOutsideOperatingHoursForRequestedStartTimes: true
3209
+ } : {})
3210
+ } : {})
3211
+ } : {})
3212
+ }).flatMap(group => group.ranges);
3213
+ 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);
3214
+ if (!refreshedCandidateBase) {
3215
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', '缓存重校验后候选时间已不在商品配置中', {
3216
+ contextId: normalizedContextId,
3217
+ candidate: params.candidate,
3218
+ refreshedSnapshotId: refreshedProjection.meta.snapshotId
3219
+ });
3220
+ }
3221
+ const product = refreshedProjection.products.find(item => sameAvailabilityId(item.id, refreshedCandidateBase.productId));
3222
+ if (!product) {
3223
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', '缓存重校验后商品不在 projection 中', {
3224
+ productId: refreshedCandidateBase.productId
3225
+ });
3226
+ }
3227
+ let validation = (0, _ResourcePlanner.validateAvailabilitySelection)(refreshedProjection, {
3228
+ candidate: refreshedCandidateBase,
3229
+ requirementSelections: params.requirementSelections,
3230
+ partySize,
3231
+ bookingCount
3232
+ });
3233
+ if (allowPosAvailabilityOverride && !validation.ok && validation.conflicts.every(conflict => POS_OVERRIDABLE_AVAILABILITY_CONFLICT_CODES.has(conflict.code))) {
3234
+ const forced = buildPosForcedAvailabilityAssignments({
3235
+ product,
3236
+ resources: refreshedProjection.resources,
3237
+ candidate: refreshedCandidateBase,
3238
+ requirementSelections: params.requirementSelections,
3239
+ partySize
3240
+ });
3241
+ validation = forced.conflicts.length > 0 ? {
3242
+ ...validation,
3243
+ assignments: [],
3244
+ conflicts: forced.conflicts
3245
+ } : {
3246
+ ...validation,
3247
+ ok: true,
3248
+ status: validation.availability.status,
3249
+ assignments: forced.assignments,
3250
+ conflicts: []
3251
+ };
3252
+ }
3253
+ if (!validation.ok) {
3254
+ const capacityConflict = validation.conflicts.find(conflict => conflict.code === 'resource_capacity_exceeded');
3255
+ throw new _ResourcePlanner.AvailabilityError('REVALIDATION_FAILED', capacityConflict?.message || '缓存重校验后资源已不可提交', {
3256
+ validation,
3257
+ refreshedSnapshotId: refreshedProjection.meta.snapshotId
3258
+ });
3259
+ }
3260
+ // Revalidation failure must leave the caller's candidate reusable. Only publish the
3261
+ // refreshed projection/version after candidate and resource validation has passed;
3262
+ // otherwise a second Confirm from the still-open modal would incorrectly become stale.
3263
+ const revalidationEntry = this.storeAvailabilityProjectionEntry({
3264
+ contextId: normalizedContextId,
3265
+ prepareParams: commitSnapshot.prepareParams,
3266
+ prepareKey: context.prepareKey,
3267
+ projection: refreshedProjection,
3268
+ orderRevision: commitSnapshot.orderRevision,
3269
+ incrementVersion: true
3270
+ });
3271
+ const revalidatedContext = this.getAvailabilityContextEntry(normalizedContextId);
3272
+ await this.emitAvailabilityDiagnostic({
3273
+ action: 'refresh',
3274
+ contextId: normalizedContextId,
3275
+ contextVersion: revalidatedContext.activeVersion,
3276
+ projection: refreshedProjection,
3277
+ snapshotId: revalidationEntry.snapshotId,
3278
+ orderRevision: revalidationEntry.orderRevision,
3279
+ previousOrderRevision: baseProjection.meta.orderRevision,
3280
+ reason: 'commit_cached_occupancy_revalidation'
3281
+ });
3282
+ commitWriteGuard = {
3283
+ contextId: normalizedContextId,
3284
+ expectedVersion: revalidatedContext.activeVersion,
3285
+ expectedRefreshNonce: revalidatedContext.refreshNonce,
3286
+ expectedLifecycleGeneration: this.availabilityLifecycleGeneration,
3287
+ expectedOrderRevision: revalidationEntry.orderRevision
1200
3288
  };
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();
3289
+ const refreshedCandidate = this.decorateAvailabilityCandidate(normalizedContextId, revalidatedContext.activeVersion, refreshedCandidateBase);
3290
+ const existingBooking = editingLine ? this.findLinkedBooking(editingLine) : undefined;
3291
+ const existingBookingUid = existingBooking ? getBookingUid(existingBooking) : '';
3292
+ if (params.mode === 'edit' && !existingBookingUid) {
3293
+ throw new _ResourcePlanner.AvailabilityError('PRODUCT_LINE_NOT_FOUND', '编辑商品行缺少关联 booking', {
3294
+ productLineUid: params.productLineUid
3295
+ });
3296
+ }
3297
+ this.assertAvailabilityCommitWriteCAS(commitWriteGuard);
3298
+ const booking = buildBookingFromAvailabilitySelection({
3299
+ assignments: validation.assignments,
3300
+ candidate: refreshedCandidate,
3301
+ product,
3302
+ resources: refreshedProjection.resources,
3303
+ bookingUid: existingBookingUid || undefined,
3304
+ holder: params.orderProduct?._extend?.holder ?? existingBooking?.holder ?? existingBooking?.metadata?.holder,
3305
+ holderIds: params.orderProduct?._extend?.holder_id ?? params.orderProduct?._extend?.holder?.form_record ?? existingBooking?.metadata?.holder_id ?? existingBooking?.holder?.form_record
3306
+ });
3307
+ const transformed = this.buildUnifiedOrderProduct({
3308
+ ...params.orderProduct,
3309
+ num: bookingCount,
3310
+ quantity: bookingCount
3311
+ }, 'unified_booking_sales', product);
3312
+ if (!transformed) {
3313
+ throw new _ResourcePlanner.AvailabilityError('INVALID_TARGET_FILTER', 'orderProduct 无法转换为订单行', {
3314
+ orderProduct: params.orderProduct
3315
+ });
3316
+ }
3317
+ transformed.num = bookingCount;
3318
+ transformed.metadata = {
3319
+ ...(transformed.metadata || {}),
3320
+ unified_booking_sales: true,
3321
+ availability_candidate_key: refreshedCandidate.key,
3322
+ availability_context_id: normalizedContextId,
3323
+ availability_context_version: refreshedCandidate.availabilityContextVersion
3324
+ };
3325
+ let productLineUid = '';
3326
+ let bookingUid = '';
3327
+ let productLineUids = [];
3328
+ let bookingUids = [];
3329
+ if (params.mode === 'edit' && editingLine && params.productLineUid) {
3330
+ transformed.product_sku = transformed.product_sku ?? editingLine.product_sku;
3331
+ transformed.product_bundle = transformed.product_bundle ?? editingLine.product_bundle;
3332
+ transformed.booking_uid = existingBookingUid;
3333
+ transformed.metadata = {
3334
+ ...(editingLine.metadata || {}),
3335
+ ...(transformed.metadata || {}),
3336
+ unique_identification_number: params.productLineUid,
3337
+ booking_uid: existingBookingUid
3338
+ };
3339
+ await this.updateOrderProduct({
3340
+ product_id: transformed.product_id ?? editingLine.product_id,
3341
+ product_variant_id: transformed.product_variant_id ?? editingLine.product_variant_id ?? 0,
3342
+ unique_identification_number: params.productLineUid,
3343
+ updates: transformed,
3344
+ booking,
3345
+ ...(params.allowBookingReprice !== undefined ? {
3346
+ allow_booking_reprice: params.allowBookingReprice
3347
+ } : {}),
3348
+ ...(params.allowBookingDiscountReapply !== undefined ? {
3349
+ allow_booking_discount_reapply: params.allowBookingDiscountReapply
3350
+ } : {})
3351
+ });
3352
+ productLineUid = params.productLineUid;
3353
+ bookingUid = existingBookingUid;
3354
+ productLineUids = [productLineUid];
3355
+ bookingUids = [bookingUid];
3356
+ } else {
3357
+ const beforeProductLineUids = new Set((this.getTempOrder()?.products || []).map(line => getProductLineUid(line)).filter(Boolean));
3358
+ const beforeBookingUids = new Set((this.getTempOrder()?.bookings || []).map(item => getBookingUid(item)).filter(Boolean));
3359
+ const products = await this.addProductToOrder(transformed, booking, params.salesNotes?.length ? {
3360
+ salesNotes: params.salesNotes
3361
+ } : undefined);
3362
+ const addedLines = products.filter(line => sameAvailabilityId(line.product_id ?? line.id, refreshedCandidate.productId) && line.metadata?.availability_candidate_key === refreshedCandidate.key && !beforeProductLineUids.has(getProductLineUid(line)));
3363
+ productLineUids = addedLines.map(line => getProductLineUid(line)).filter(Boolean);
3364
+ const addedBookings = (this.getTempOrder()?.bookings || []).filter(item => item.metadata?.availability_candidate_key === refreshedCandidate.key && !beforeBookingUids.has(getBookingUid(item)));
3365
+ bookingUids = addedBookings.map(item => getBookingUid(item)).filter(Boolean);
3366
+ if (bookingUids.length === 0) {
3367
+ bookingUids = addedLines.map(line => String(line.booking_uid ?? line.metadata?.booking_uid ?? '')).filter(Boolean);
3368
+ }
3369
+ productLineUid = productLineUids[productLineUids.length - 1] || '';
3370
+ bookingUid = bookingUids[bookingUids.length - 1] || '';
3371
+ }
3372
+ this.invalidateAvailabilityContextsAfterCommit();
3373
+ const nextContext = this.getAvailabilityContextEntry(normalizedContextId);
3374
+ const result = {
3375
+ contextId: normalizedContextId,
3376
+ contextVersion: nextContext.activeVersion,
3377
+ tempOrder: this.getTempOrder(),
3378
+ validation,
3379
+ candidate: refreshedCandidate,
3380
+ productLineUid,
3381
+ bookingUid,
3382
+ productLineUids,
3383
+ bookingUids
3384
+ };
3385
+ await this.core.effects.emit(`${this.name}:${_types.UnifiedBookingSalesHooks.onAvailabilitySelectionCommitted}`, result);
3386
+ return result;
3387
+ } finally {
3388
+ await this.releaseAvailabilityCommitLock(normalizedContextId);
1211
3389
  }
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
3390
  }
1221
3391
  }
1222
3392
  exports.UnifiedBookingSalesImpl = UnifiedBookingSalesImpl;