@pisell/pisellos 2.2.292 → 2.2.293

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) 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 +128 -74
  74. package/dist/solution/UnifiedBookingSales/index.js +3877 -963
  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/modules/BookingContext/index.d.ts +3 -0
  96. package/lib/modules/BookingContext/index.js +35 -8
  97. package/lib/modules/BookingContext/types.d.ts +4 -4
  98. package/lib/modules/BookingContext/types.js +3 -3
  99. package/lib/modules/OpenData/types.d.ts +1 -0
  100. package/lib/modules/Order/index.d.ts +19 -4
  101. package/lib/modules/Order/index.js +325 -28
  102. package/lib/modules/Order/salesNotes.d.ts +31 -0
  103. package/lib/modules/Order/salesNotes.js +382 -0
  104. package/lib/modules/Order/types.d.ts +93 -5
  105. package/lib/modules/Order/types.js +4 -0
  106. package/lib/modules/Order/utils.d.ts +12 -0
  107. package/lib/modules/Order/utils.js +85 -16
  108. package/lib/modules/Product/types.d.ts +2 -0
  109. package/lib/modules/ProductList/clientDataVariants.d.ts +50 -0
  110. package/lib/modules/ProductList/clientDataVariants.js +204 -0
  111. package/lib/modules/ProductList/index.d.ts +18 -3
  112. package/lib/modules/ProductList/index.js +137 -8
  113. package/lib/modules/ProductList/types.d.ts +10 -0
  114. package/lib/modules/Quotation/index.d.ts +22 -0
  115. package/lib/modules/Quotation/index.js +158 -11
  116. package/lib/modules/ResourcePlanner/index.d.ts +0 -22
  117. package/lib/modules/ResourcePlanner/index.js +9 -129
  118. package/lib/modules/ResourcePlanner/localClock.d.ts +33 -0
  119. package/lib/modules/ResourcePlanner/localClock.js +205 -0
  120. package/lib/modules/ResourcePlanner/planner.d.ts +5 -14
  121. package/lib/modules/ResourcePlanner/planner.js +1807 -783
  122. package/lib/modules/ResourcePlanner/types.d.ts +359 -187
  123. package/lib/modules/ResourcePlanner/types.js +13 -1
  124. package/lib/plugins/request.d.ts +1 -0
  125. package/lib/server/index.d.ts +17 -2
  126. package/lib/server/index.js +149 -21
  127. package/lib/server/modules/floor-plan/index.d.ts +4 -0
  128. package/lib/server/modules/floor-plan/index.js +60 -7
  129. package/lib/server/modules/menu/index.js +26 -4
  130. package/lib/server/modules/order/index.d.ts +17 -7
  131. package/lib/server/modules/order/index.js +194 -57
  132. package/lib/server/modules/products/index.d.ts +10 -4
  133. package/lib/server/modules/products/index.js +93 -23
  134. package/lib/server/modules/resource/index.js +12 -2
  135. package/lib/server/utils/product.d.ts +1 -1
  136. package/lib/server/utils/product.js +163 -3
  137. package/lib/server/utils/small-ticket.d.ts +9 -1
  138. package/lib/server/utils/small-ticket.js +126 -33
  139. package/lib/solution/BaseSales/index.d.ts +26 -3
  140. package/lib/solution/BaseSales/index.js +396 -70
  141. package/lib/solution/BaseSales/types.d.ts +2 -0
  142. package/lib/solution/BaseSales/utils/transformBaseProductToOrderProduct.d.ts +3 -0
  143. package/lib/solution/BaseSales/utils/transformBaseProductToOrderProduct.js +9 -2
  144. package/lib/solution/BaseSales/utils.js +3 -0
  145. package/lib/solution/BookingByStep/index.d.ts +4 -4
  146. package/lib/solution/BookingTicket/index.d.ts +1 -1
  147. package/lib/solution/BookingTicket/index.js +2 -1
  148. package/lib/solution/BookingTicket/utils/addProductDecision.d.ts +2 -1
  149. package/lib/solution/BookingTicket/utils/addProductDecision.js +5 -2
  150. package/lib/solution/BookingTicket/utils/orderCustomer.js +2 -1
  151. package/lib/solution/RegisterAndLogin/config.d.ts +9 -3
  152. package/lib/solution/RegisterAndLogin/config.js +49 -8
  153. package/lib/solution/RegisterAndLogin/index.js +4 -1
  154. package/lib/solution/ScanOrder/index.d.ts +1 -0
  155. package/lib/solution/ScanOrder/index.js +5 -1
  156. package/lib/solution/ScanOrder/types.d.ts +2 -0
  157. package/lib/solution/ScanOrder/utils.d.ts +1 -0
  158. package/lib/solution/ScanOrder/utils.js +4 -0
  159. package/lib/solution/UnifiedBookingSales/index.d.ts +128 -74
  160. package/lib/solution/UnifiedBookingSales/index.js +2592 -424
  161. package/lib/solution/UnifiedBookingSales/remoteOccupancyCache.d.ts +49 -0
  162. package/lib/solution/UnifiedBookingSales/remoteOccupancyCache.js +316 -0
  163. package/lib/solution/UnifiedBookingSales/types.d.ts +161 -77
  164. package/lib/solution/UnifiedBookingSales/types.js +4 -6
  165. package/lib/solution/VenueBooking/index.d.ts +3 -0
  166. package/lib/solution/VenueBooking/index.js +33 -9
  167. package/lib/solution/VenueBooking/utils/dateSummary.d.ts +1 -0
  168. package/lib/solution/VenueBooking/utils/dateSummary.js +3 -1
  169. package/lib/solution/VenueBooking/utils/slotMerge.d.ts +2 -0
  170. package/lib/solution/VenueBooking/utils/slotMerge.js +3 -1
  171. package/lib/utils/platform.d.ts +8 -0
  172. package/lib/utils/platform.js +22 -0
  173. package/package.json +1 -1
@@ -3,893 +3,1917 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.DEFAULT_RESOURCE_PLANNER_SELECTION = exports.DEFAULT_RESOURCE_PLANNER_CONTEXT = void 0;
7
- exports.autoAssignResources = autoAssignResources;
8
- exports.buildDemandIntervals = buildDemandIntervals;
9
- exports.evaluateResourceForInterval = evaluateResourceForInterval;
10
- exports.normalizePlannerContext = normalizePlannerContext;
11
- exports.queryPlannerAvailability = queryPlannerAvailability;
12
- exports.resolveAssignableSlots = resolveAssignableSlots;
13
- exports.validateAssignments = validateAssignments;
14
- var _dayjs = _interopRequireDefault(require("dayjs"));
15
- function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
16
- const DEFAULT_RESOURCE_PLANNER_CONTEXT = exports.DEFAULT_RESOURCE_PLANNER_CONTEXT = {
17
- products: [],
18
- resources: [],
19
- externalEvents: [],
20
- slotDurationMinutes: 30,
21
- businessStartTime: '09:00',
22
- businessEndTime: '18:00'
6
+ exports.createAvailabilityProjection = createAvailabilityProjection;
7
+ exports.getProductTimeRanges = getProductTimeRanges;
8
+ exports.queryAvailabilityProjection = queryAvailabilityProjection;
9
+ exports.validateAvailabilitySelection = validateAvailabilitySelection;
10
+ var _types = require("./types");
11
+ var _localClock = require("./localClock");
12
+ const DEFAULT_SLOT_STEP_MINUTES = 30;
13
+ const DEFAULT_BUSINESS_HOURS = {
14
+ startTime: '00:00',
15
+ endTime: '00:00'
23
16
  };
24
- const DEFAULT_RESOURCE_PLANNER_SELECTION = exports.DEFAULT_RESOURCE_PLANNER_SELECTION = {
25
- productIds: [],
26
- resourceIds: [],
27
- assignments: []
28
- };
29
- function normalizePlannerContext(input = {}) {
30
- return {
31
- products: Array.isArray(input.products) ? input.products : [],
32
- resources: Array.isArray(input.resources) ? input.resources : [],
33
- externalEvents: Array.isArray(input.externalEvents) ? input.externalEvents : [],
34
- slotDurationMinutes: input.slotDurationMinutes || DEFAULT_RESOURCE_PLANNER_CONTEXT.slotDurationMinutes,
35
- businessStartTime: input.businessStartTime || DEFAULT_RESOURCE_PLANNER_CONTEXT.businessStartTime,
36
- businessEndTime: input.businessEndTime || DEFAULT_RESOURCE_PLANNER_CONTEXT.businessEndTime
37
- };
17
+ function availabilityError(code, message, details) {
18
+ return new _types.AvailabilityError(code, message, details);
38
19
  }
39
20
  function sameId(left, right) {
21
+ if (left === undefined || left === null || right === undefined || right === null) return false;
40
22
  return String(left) === String(right);
41
23
  }
42
- function dateOf(value) {
43
- return (0, _dayjs.default)(value).format('YYYY-MM-DD');
44
- }
45
- function timeOf(value) {
46
- return (0, _dayjs.default)(value).format('HH:mm');
47
- }
48
- function toDateTime(date, time) {
49
- if (/^\d{4}-\d{2}-\d{2}/.test(time)) return (0, _dayjs.default)(time);
50
- return (0, _dayjs.default)(`${date} ${time.length === 5 ? `${time}:00` : time}`);
51
- }
52
- function overlaps(startA, endA, startB, endB) {
53
- return startA.isBefore(endB) && endA.isAfter(startB);
54
- }
55
- function containsRange(outerStart, outerEnd, innerStart, innerEnd) {
56
- return !innerStart.isBefore(outerStart) && !innerEnd.isAfter(outerEnd);
57
- }
58
- function maxDayjs(left, right) {
59
- return left.isAfter(right) ? left : right;
60
- }
61
- function minDayjs(left, right) {
62
- return left.isBefore(right) ? left : right;
63
- }
64
- function addDays(start, end) {
65
- const result = [];
66
- let cursor = (0, _dayjs.default)(start);
67
- const finalDay = (0, _dayjs.default)(end);
68
- while (!cursor.isAfter(finalDay, 'day')) {
69
- result.push(cursor.format('YYYY-MM-DD'));
70
- cursor = cursor.add(1, 'day');
71
- }
72
- return result;
73
- }
74
- function getQueryProducts(products, query) {
75
- if (!query.productIds?.length) return products;
76
- return products.filter(product => query.productIds.some(id => sameId(id, product.id)));
77
- }
78
- function getQueryResources(resources, query) {
79
- if (!query.resourceIds?.length) return resources;
80
- return resources.filter(resource => query.resourceIds.some(id => sameId(id, resource.id)));
81
- }
82
- function matchesResourceRequirement(resource, requirement) {
83
- const isFormlessCandidate = resource.formId == null;
84
- const formMatches = requirement.formId == null || isFormlessCandidate || sameId(requirement.formId, resource.formId);
85
- const resourceMatches = !requirement.resourceIds?.length || isFormlessCandidate || requirement.resourceIds.some(id => sameId(id, resource.id));
86
- return formMatches && resourceMatches;
87
- }
88
- function resolveProductTitle(product) {
89
- const rawTitle = product.raw?.title || product.raw?.name || product.raw?.product_title;
90
- if (typeof product.title === 'string') return product.title;
91
- if (typeof rawTitle === 'string') return rawTitle;
92
- if (rawTitle && typeof rawTitle === 'object') {
93
- return rawTitle.en || rawTitle.zh || rawTitle.default || Object.values(rawTitle)[0];
94
- }
95
- return undefined;
96
- }
97
- function buildDemandIntervals(products, query, context) {
98
- const dates = query.date ? [query.date] : query.dateRange ? addDays(query.dateRange.start, query.dateRange.end) : [];
99
- const selectedProducts = getQueryProducts(products, query);
100
- const intervals = [];
101
- for (const product of selectedProducts) {
102
- const quantity = Math.max(1, Number(product.quantity || 1));
103
- const capacityRequired = Math.max(1, Number(query.pax || product.capacityRequired || 1));
104
- const requirements = product.resourceRequirements || [];
105
- if (product.kind === 'session' || product.kind === 'venue_slot') {
106
- const schedules = product.schedule || [];
107
- for (const schedule of schedules) {
108
- const scheduleDate = dateOf(schedule.start_at);
109
- if (dates.length > 0 && !dates.includes(scheduleDate)) continue;
110
- const start = (0, _dayjs.default)(schedule.start_at);
111
- const end = (0, _dayjs.default)(schedule.end_at);
112
- intervals.push({
113
- id: `${product.id}:${start.toISOString()}:${end.toISOString()}`,
114
- productId: product.id,
115
- source: product.kind,
116
- date: dateOf(schedule.start_at),
117
- start_at: start.toISOString(),
118
- end_at: end.toISOString(),
119
- durationMinutes: Math.max(1, end.diff(start, 'minute')),
120
- quantity,
121
- capacityRequired,
122
- resourceRequirements: requirements
123
- });
124
- }
125
- continue;
126
- }
127
- if (product.kind === 'duration') {
128
- const duration = Math.max(1, Number(product.durationMinutes || 30));
129
- for (const currentDate of dates) {
130
- const start = query.startTime ? toDateTime(currentDate, query.startTime) : toDateTime(currentDate, context?.businessStartTime || DEFAULT_RESOURCE_PLANNER_CONTEXT.businessStartTime);
131
- let end = query.startTime && query.endTime ? toDateTime(currentDate, query.endTime) : query.startTime ? start.add(duration, 'minute') : toDateTime(currentDate, context?.businessEndTime || DEFAULT_RESOURCE_PLANNER_CONTEXT.businessEndTime);
132
- if (!end.isAfter(start)) end = end.add(1, 'day');
133
- intervals.push({
134
- id: `${product.id}:${currentDate}:${query.startTime || 'range'}`,
135
- productId: product.id,
136
- source: 'duration',
137
- date: currentDate,
138
- start_at: start.toISOString(),
139
- end_at: end.toISOString(),
140
- durationMinutes: duration,
141
- quantity,
142
- capacityRequired,
143
- resourceRequirements: requirements
144
- });
145
- }
146
- }
24
+ function uniqById(values = []) {
25
+ const map = new Map();
26
+ values.forEach(value => {
27
+ map.set(String(value), value);
28
+ });
29
+ return Array.from(map.values());
30
+ }
31
+ function normalizePartySize(partySize) {
32
+ if (partySize === undefined || partySize === null) return undefined;
33
+ const normalized = Number(partySize);
34
+ if (!Number.isInteger(normalized) || normalized <= 0) {
35
+ throw availabilityError('INVALID_TARGET_FILTER', 'partySize 必须是正整数', {
36
+ partySize
37
+ });
147
38
  }
148
- return intervals;
39
+ return normalized;
149
40
  }
150
- function getEventsForResource(resource, contextEvents) {
151
- const embeddedEvents = resource.times.flatMap(time => time.event_list || []);
152
- return [...embeddedEvents, ...contextEvents.filter(event => sameId(event.resourceId ?? event.resource_id, resource.id))].filter(Boolean);
41
+ function normalizeBookingCount(bookingCount) {
42
+ if (bookingCount === undefined || bookingCount === null) return 1;
43
+ const normalized = Number(bookingCount);
44
+ if (!Number.isInteger(normalized) || normalized <= 0) {
45
+ throw availabilityError('INVALID_TARGET_FILTER', 'bookingCount 必须是正整数', {
46
+ bookingCount
47
+ });
48
+ }
49
+ return normalized;
153
50
  }
154
- function collectEventsByResource(resources, contextEvents) {
155
- const eventsByResource = new Map();
156
- resources.forEach(resource => {
157
- eventsByResource.set(String(resource.id), getEventsForResource(resource, contextEvents));
158
- });
159
- return eventsByResource;
51
+ function resolveRequestedDurationMinutes(product, request) {
52
+ const configured = Math.max(1, Number(product.durationMinutes || 30) || 30);
53
+ if (product.kind !== 'duration') return configured;
54
+ const override = request.durationMinutesByProductId?.[String(product.id)];
55
+ const normalized = Number(override);
56
+ return Number.isInteger(normalized) && normalized > 0 ? normalized : configured;
57
+ }
58
+ function resolveRequestedDurationStartTimes(product, request) {
59
+ if (product.kind !== 'duration') return [];
60
+ const values = request.durationStartTimesByProductId?.[String(product.id)];
61
+ if (!Array.isArray(values)) return [];
62
+ return Array.from(new Set(values.filter(value => typeof value === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(value))));
63
+ }
64
+ function toDateTime(value, _timezoneName) {
65
+ try {
66
+ return (0, _localClock.localDateTimeToScalar)((0, _localClock.parseLocalDateTime)(value));
67
+ } catch (_error) {
68
+ return Number.NaN;
69
+ }
70
+ }
71
+ function toDateStart(value, _timezoneName) {
72
+ try {
73
+ return (0, _localClock.localDateToScalarStart)(value);
74
+ } catch (_error) {
75
+ return Number.NaN;
76
+ }
77
+ }
78
+ function toDateEnd(value, _timezoneName) {
79
+ const start = toDateStart(value);
80
+ return Number.isFinite(start) ? (0, _localClock.addLocalCalendarDays)(start, 1) - 1 : Number.NaN;
81
+ }
82
+ function toDateTimeInDate(date, time, _timezoneName) {
83
+ return toDateTime(`${date}T${time.length === 5 ? `${time}:00` : time}`);
84
+ }
85
+ function addMinutesZoned(value, minutes, _timezoneName) {
86
+ return (0, _localClock.addLocalMinutes)(value, minutes);
87
+ }
88
+ function ceilToTenMinutes(value, _timezoneName) {
89
+ return (0, _localClock.ceilLocalScalarToTenMinutes)(value);
90
+ }
91
+ function floorToMinute(value) {
92
+ return Math.floor(value / 60000) * 60000;
93
+ }
94
+ function nextDate(value, _timezoneName) {
95
+ return (0, _localClock.nextDate)(value);
160
96
  }
161
- function getWorkingSegmentsForInterval(resource, interval) {
162
- const intervalStart = (0, _dayjs.default)(interval.start_at);
163
- const intervalEnd = (0, _dayjs.default)(interval.end_at);
164
- return resource.times.map(time => {
165
- const start = maxDayjs((0, _dayjs.default)(time.start_at), intervalStart);
166
- const end = minDayjs((0, _dayjs.default)(time.end_at), intervalEnd);
97
+ function formatDateTime(value, _timezoneName, fixedOffsetMinutes) {
98
+ return (0, _localClock.formatLocalDateTime)(value, fixedOffsetMinutes ?? (0, _localClock.captureRuntimeOffsetMinutes)());
99
+ }
100
+ function formatDate(value, _timezoneName) {
101
+ return (0, _localClock.formatLocalDate)(value);
102
+ }
103
+ function formatTime(value, _timezoneName) {
104
+ return (0, _localClock.formatLocalTime)(value, false);
105
+ }
106
+ function normalizeAbsoluteRange(range, _timezoneName) {
107
+ const start = toDateTime(range.startAt);
108
+ const end = toDateTime(range.endAt);
109
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
110
+ return {
111
+ start,
112
+ end
113
+ };
114
+ }
115
+ function getCandidateBookingRange(candidate) {
116
+ return {
117
+ // Runtime fallback keeps older caller-created candidates fail-safe during migration.
118
+ startAt: candidate.bookingStartAt || candidate.startAt,
119
+ endAt: candidate.bookingEndAt || candidate.endAt
120
+ };
121
+ }
122
+ function getCandidateRangeCacheKey(candidate) {
123
+ const bookingRange = getCandidateBookingRange(candidate);
124
+ return `${candidate.key}:${bookingRange.startAt}:${bookingRange.endAt}`;
125
+ }
126
+ function getProductTimeRangeIdentity(range) {
127
+ const bookingRange = getCandidateBookingRange(range);
128
+ return [String(range.productId), range.startAt, range.endAt, bookingRange.startAt, bookingRange.endAt].join(':');
129
+ }
130
+ function toIndexedRange(range) {
131
+ return {
132
+ ...range,
133
+ startMs: range.start,
134
+ endMs: range.end
135
+ };
136
+ }
137
+ function normalizeNonNegativeNumber(value) {
138
+ const normalized = Number(value);
139
+ if (!Number.isFinite(normalized) || normalized < 0) return null;
140
+ return normalized;
141
+ }
142
+ function resolveCutOffPolicyIssue(policy) {
143
+ if (policy.type === 'invalid') {
144
+ return policy.reason || 'invalid_cutoff_policy';
145
+ }
146
+ if ('futureDay' in policy && policy.futureDay !== undefined && normalizeNonNegativeNumber(policy.futureDay) === null) {
147
+ return 'invalid_future_day';
148
+ }
149
+ if (policy.type === 'ongoing' && policy.mode === 'after_start' && normalizeNonNegativeNumber(policy.graceMinutes) === null) {
150
+ return 'invalid_grace_minutes';
151
+ }
152
+ if (policy.type === 'advance') {
153
+ if (normalizeNonNegativeNumber(policy.unit) === null) return 'invalid_advance_unit';
154
+ if (!['days', 'hours', 'minutes'].includes(policy.unitType)) return 'invalid_advance_unit_type';
155
+ }
156
+ return null;
157
+ }
158
+ function addLeadWindow(base, unit, unitType, _timezoneName) {
159
+ if (unitType === 'days') return (0, _localClock.addLocalCalendarDays)(base, unit);
160
+ if (unitType === 'hours') return (0, _localClock.addLocalHours)(base, unit);
161
+ return (0, _localClock.addLocalMinutes)(base, unit);
162
+ }
163
+ function evaluateCandidateTimeGate(params) {
164
+ const {
165
+ product,
166
+ candidateRange,
167
+ evaluationContext,
168
+ timezoneName
169
+ } = params;
170
+ const now = evaluationContext.evaluatedAt;
171
+ if (candidateRange.endMs <= evaluationContext.evaluatedAtMs) {
167
172
  return {
168
- start,
169
- end
173
+ status: 'past',
174
+ reasonCodes: ['past'],
175
+ conflictCode: 'past'
176
+ };
177
+ }
178
+ const policy = product.cutOffPolicy;
179
+ if (!policy) return null;
180
+ if (resolveCutOffPolicyIssue(policy)) {
181
+ return {
182
+ status: 'unavailable',
183
+ reasonCodes: ['booking_cutoff_invalid'],
184
+ conflictCode: 'booking_cutoff'
185
+ };
186
+ }
187
+ if ('futureDay' in policy && policy.futureDay !== undefined) {
188
+ const futureDay = normalizeNonNegativeNumber(policy.futureDay);
189
+ const lastAllowedDateOrdinal = (0, _localClock.dateOrdinalFromLocalDate)(formatDate(now)) + futureDay;
190
+ if ((0, _localClock.dateOrdinalFromLocalDate)(formatDate(candidateRange.start)) > lastAllowedDateOrdinal) {
191
+ return {
192
+ status: 'unavailable',
193
+ reasonCodes: ['booking_cutoff_future_day'],
194
+ conflictCode: 'booking_cutoff'
195
+ };
196
+ }
197
+ }
198
+ if (policy.type === 'ongoing') {
199
+ if (policy.mode === 'before_end') return null;
200
+ const graceMinutes = normalizeNonNegativeNumber(policy.graceMinutes);
201
+ const graceDeadline = addMinutesZoned(candidateRange.start, graceMinutes, timezoneName);
202
+ if (now < graceDeadline) return null;
203
+ return {
204
+ status: 'unavailable',
205
+ reasonCodes: ['booking_cutoff_after_start'],
206
+ conflictCode: 'booking_cutoff'
170
207
  };
171
- }).filter(segment => segment.end.isAfter(segment.start));
208
+ }
209
+ if (policy.type === 'before_start') {
210
+ if (now < candidateRange.start) return null;
211
+ return {
212
+ status: 'unavailable',
213
+ reasonCodes: ['booking_cutoff_before_start'],
214
+ conflictCode: 'booking_cutoff'
215
+ };
216
+ }
217
+ if (policy.type === 'advance') {
218
+ const leadWindow = addLeadWindow(now, normalizeNonNegativeNumber(policy.unit), policy.unitType, timezoneName);
219
+ if (leadWindow <= candidateRange.start) return null;
220
+ return {
221
+ status: 'unavailable',
222
+ reasonCodes: ['booking_cutoff_advance'],
223
+ conflictCode: 'booking_cutoff'
224
+ };
225
+ }
226
+ return {
227
+ status: 'unavailable',
228
+ reasonCodes: ['booking_cutoff_invalid'],
229
+ conflictCode: 'booking_cutoff'
230
+ };
172
231
  }
173
- function mergeBusySegments(segments) {
174
- const sorted = [...segments].filter(segment => segment.end.isAfter(segment.start)).sort((left, right) => left.start.valueOf() - right.start.valueOf());
232
+ function overlaps(left, right) {
233
+ return left.start < right.end && left.end > right.start;
234
+ }
235
+ function contains(outer, inner) {
236
+ return inner.start >= outer.start && inner.end <= outer.end;
237
+ }
238
+ function clipRange(base, clip) {
239
+ const start = Math.max(base.start, clip.start);
240
+ const end = Math.min(base.end, clip.end);
241
+ if (end <= start) return null;
242
+ return {
243
+ start,
244
+ end
245
+ };
246
+ }
247
+ function sortRanges(ranges) {
248
+ return [...ranges].sort((left, right) => left.start - right.start);
249
+ }
250
+ function mergeRanges(ranges) {
251
+ const sorted = sortRanges(ranges);
175
252
  const merged = [];
176
- sorted.forEach(segment => {
177
- const previous = merged[merged.length - 1];
178
- if (previous && !segment.start.isAfter(previous.end)) {
179
- previous.end = maxDayjs(previous.end, segment.end);
180
- } else {
181
- merged.push({
182
- ...segment
183
- });
253
+ sorted.forEach(range => {
254
+ const last = merged[merged.length - 1];
255
+ if (!last) {
256
+ merged.push(range);
257
+ return;
184
258
  }
259
+ if (range.start <= last.end) {
260
+ last.end = Math.max(range.end, last.end);
261
+ return;
262
+ }
263
+ merged.push(range);
185
264
  });
186
265
  return merged;
187
266
  }
188
- function subtractBusyIntervals(workingSegments, events) {
189
- const available = [];
190
- workingSegments.forEach(working => {
191
- const busySegments = mergeBusySegments(events.filter(event => overlaps(working.start, working.end, (0, _dayjs.default)(event.start_at), (0, _dayjs.default)(event.end_at))).map(event => ({
192
- start: maxDayjs((0, _dayjs.default)(event.start_at), working.start),
193
- end: minDayjs((0, _dayjs.default)(event.end_at), working.end)
194
- })));
195
- let cursor = working.start;
196
- busySegments.forEach(busy => {
197
- if (busy.start.isAfter(cursor)) {
198
- available.push({
199
- start: cursor,
200
- end: busy.start
201
- });
202
- }
203
- if (busy.end.isAfter(cursor)) cursor = busy.end;
267
+ function intersectRangeLists(left, right) {
268
+ const results = [];
269
+ left.forEach(leftRange => {
270
+ right.forEach(rightRange => {
271
+ const clipped = clipRange(leftRange, rightRange);
272
+ if (clipped) results.push(clipped);
204
273
  });
205
- if (working.end.isAfter(cursor)) {
206
- available.push({
207
- start: cursor,
208
- end: working.end
209
- });
210
- }
211
274
  });
212
- return available;
275
+ return mergeRanges(results);
213
276
  }
214
- function buildCapacityRanges(params) {
215
- const {
216
- workingSegments,
217
- events,
218
- maxCapacity,
219
- requiredCapacity
220
- } = params;
221
- const ranges = [];
222
- workingSegments.forEach(working => {
223
- const boundaries = new Map([[working.start.valueOf(), working.start], [working.end.valueOf(), working.end]]);
224
- events.forEach(event => {
225
- const eventStart = (0, _dayjs.default)(event.start_at);
226
- const eventEnd = (0, _dayjs.default)(event.end_at);
227
- if (!overlaps(working.start, working.end, eventStart, eventEnd)) return;
228
- const clippedStart = maxDayjs(eventStart, working.start);
229
- const clippedEnd = minDayjs(eventEnd, working.end);
230
- boundaries.set(clippedStart.valueOf(), clippedStart);
231
- boundaries.set(clippedEnd.valueOf(), clippedEnd);
277
+ function enumerateDates(startDate, endDate) {
278
+ const dates = [];
279
+ let cursor = startDate;
280
+ while (cursor <= endDate) {
281
+ dates.push(cursor);
282
+ cursor = nextDate(cursor);
283
+ }
284
+ return dates;
285
+ }
286
+ function normalizeCoverage(input) {
287
+ const startDate = input.coverage.startDate;
288
+ const endDate = input.coverage.endDate;
289
+ const isCanonicalDate = value => {
290
+ try {
291
+ return (0, _localClock.formatLocalDate)((0, _localClock.parseLocalDate)(value)) === value;
292
+ } catch (_error) {
293
+ return false;
294
+ }
295
+ };
296
+ if (!startDate || !endDate || !isCanonicalDate(startDate) || !isCanonicalDate(endDate)) {
297
+ throw availabilityError('INVALID_DATE_RANGE', 'coverage.startDate / coverage.endDate 非法', {
298
+ coverage: input.coverage
232
299
  });
233
- const points = Array.from(boundaries.values()).sort((left, right) => left.valueOf() - right.valueOf());
234
- for (let index = 0; index < points.length - 1; index += 1) {
235
- const start = points[index];
236
- const end = points[index + 1];
237
- if (!end.isAfter(start)) continue;
238
- const usedCapacity = events.filter(event => overlaps(start, end, (0, _dayjs.default)(event.start_at), (0, _dayjs.default)(event.end_at))).reduce((sum, event) => sum + Math.max(1, Number(event.pax || 1)), 0);
239
- const remainingCapacity = Math.max(0, maxCapacity - usedCapacity);
240
- if (remainingCapacity < requiredCapacity) continue;
241
- const status = remainingCapacity < maxCapacity ? 'limited' : 'available';
242
- const previous = ranges[ranges.length - 1];
243
- if (previous && previous.end.isSame(start) && previous.remainingCapacity === remainingCapacity && previous.maxCapacity === maxCapacity && previous.status === status) {
244
- previous.end = end;
245
- } else {
246
- ranges.push({
247
- start,
248
- end,
249
- remainingCapacity,
250
- maxCapacity,
251
- status
252
- });
253
- }
300
+ }
301
+ if (endDate < startDate) {
302
+ throw availabilityError('INVALID_DATE_RANGE', 'coverage.endDate 不能早于 startDate', {
303
+ coverage: input.coverage
304
+ });
305
+ }
306
+ return {
307
+ dateRange: {
308
+ startDate,
309
+ endDate
310
+ },
311
+ absolute: {
312
+ start: toDateStart(startDate, input.timezone),
313
+ end: toDateEnd(nextDate(endDate, input.timezone), input.timezone)
254
314
  }
255
- });
256
- return ranges;
315
+ };
257
316
  }
258
- function rangeFitsDuration(segment, durationMinutes) {
259
- return segment.end.diff(segment.start, 'minute') >= durationMinutes;
317
+ function normalizeResourceWindow(resource, coverage, timezoneName) {
318
+ return mergeRanges((resource.windows || []).map(window => normalizeAbsoluteRange(window, timezoneName)).filter(window => Boolean(window)).map(window => clipRange(window, coverage)).filter(window => Boolean(window)));
260
319
  }
261
- function makeAvailabilityRangeCell(params) {
262
- const {
263
- interval,
264
- resource,
265
- segment
266
- } = params;
267
- const maxCapacity = Math.max(1, Number(segment.maxCapacity || resource.capacity || 1));
268
- const remainingCapacity = resource.resourceType === 'single' ? 1 : Math.max(0, Number(segment.remainingCapacity ?? maxCapacity));
269
- const status = resource.resourceType === 'single' ? 'available' : segment.status || (remainingCapacity < maxCapacity ? 'limited' : 'available');
320
+ function normalizeOccupancy(occupancy, coverage, timezoneName, fixedOffsetMinutes) {
321
+ const normalized = normalizeAbsoluteRange(occupancy, timezoneName);
322
+ if (!normalized) return null;
323
+ const clipped = clipRange(normalized, coverage);
324
+ if (!clipped) return null;
270
325
  return {
271
- key: `${interval.productId}:${resource.id}:${segment.start.toISOString()}:${segment.end.toISOString()}`,
272
- productId: interval.productId,
273
- resourceId: resource.id,
274
- date: interval.date,
275
- start_at: segment.start.toISOString(),
276
- end_at: segment.end.toISOString(),
277
- startTime: segment.start.format('HH:mm'),
278
- endTime: segment.end.format('HH:mm'),
279
- durationMinutes: interval.durationMinutes,
280
- cellType: 'availability_range',
281
- status,
282
- remainingCapacity,
283
- maxCapacity,
284
- reasonCodes: [],
285
- conflicts: []
326
+ ...occupancy,
327
+ startAt: formatDateTime(clipped.start, timezoneName, fixedOffsetMinutes),
328
+ endAt: formatDateTime(clipped.end, timezoneName, fixedOffsetMinutes),
329
+ pax: Math.max(1, Number(occupancy.pax || 1) || 1)
286
330
  };
287
331
  }
288
- function buildAvailabilityCellsForResource(params) {
289
- const {
290
- resource,
291
- interval,
292
- events
293
- } = params;
294
- const requiredCapacity = Math.max(1, Number(interval.capacityRequired || 1));
295
- const durationMinutes = Math.max(1, Number(interval.durationMinutes || 1));
296
- const maxCapacity = Math.max(1, Number(resource.capacity || 1));
297
- let workingSegments = getWorkingSegmentsForInterval(resource, interval);
298
- const now = (0, _dayjs.default)();
299
- workingSegments = workingSegments.map(segment => ({
300
- start: segment.end.isAfter(now) ? maxDayjs(segment.start, now) : segment.start,
301
- end: segment.end
302
- })).filter(segment => segment.end.isAfter(segment.start));
303
- const availableSegments = resource.resourceType === 'single' ? subtractBusyIntervals(workingSegments, events).map(segment => ({
304
- ...segment,
305
- remainingCapacity: 1,
306
- maxCapacity: 1,
307
- status: 'available'
308
- })) : buildCapacityRanges({
309
- workingSegments,
310
- events,
311
- maxCapacity,
312
- requiredCapacity
332
+ function normalizeRequirementGroup(product, requirementGroup) {
333
+ const resourceType = 'single';
334
+ const required = requirementGroup.required !== false;
335
+ const min = required ? 1 : 0;
336
+ return {
337
+ ...requirementGroup,
338
+ id: requirementGroup.id || `group:${String(product.id)}:${String(requirementGroup.formId ?? resourceType)}`,
339
+ resourceType,
340
+ required,
341
+ min,
342
+ max: 1,
343
+ resourceIds: uniqById(requirementGroup.resourceIds || []),
344
+ capacityRequired: Math.max(1, Number(requirementGroup.capacityRequired || product.capacityRequired || 1) || 1)
345
+ };
346
+ }
347
+ function normalizeProducts(products) {
348
+ return products.map(product => ({
349
+ ...product,
350
+ quantity: Math.max(1, Number(product.quantity || 1) || 1),
351
+ capacityRequired: Math.max(1, Number(product.capacityRequired || 1) || 1),
352
+ durationMinutes: Math.max(1, Number(product.durationMinutes || 30) || 30),
353
+ cutOffPolicy: product.cutOffPolicy ? {
354
+ ...product.cutOffPolicy
355
+ } : undefined,
356
+ requirementGroups: (product.requirementGroups || []).map(group => normalizeRequirementGroup(product, group)),
357
+ sessionRules: (product.sessionRules || []).map(rule => ({
358
+ ...rule,
359
+ weekdays: rule.weekdays ? [...rule.weekdays] : undefined,
360
+ dates: rule.dates ? [...rule.dates] : undefined,
361
+ includeDates: rule.includeDates ? [...rule.includeDates] : undefined,
362
+ excludeDates: rule.excludeDates ? [...rule.excludeDates] : undefined,
363
+ scheduleRef: rule.scheduleRef ? {
364
+ ...rule.scheduleRef
365
+ } : undefined
366
+ })),
367
+ sessionRanges: (product.sessionRanges || []).map(range => ({
368
+ ...range,
369
+ scheduleRefs: range.scheduleRefs?.map(scheduleRef => ({
370
+ ...scheduleRef
371
+ }))
372
+ }))
373
+ }));
374
+ }
375
+ function normalizeResources(resources) {
376
+ return resources.map(resource => ({
377
+ ...resource,
378
+ resourceType: 'single',
379
+ capacity: Math.max(1, Number(resource.capacity || 1) || 1),
380
+ windows: (resource.windows || []).map(window => ({
381
+ ...window
382
+ }))
383
+ }));
384
+ }
385
+ function normalizeOperatingHours(params) {
386
+ const fallbackWindows = enumerateDates(params.dateRange.startDate, params.dateRange.endDate).map(date => {
387
+ const start = toDateTimeInDate(date, params.businessHours.startTime, params.timezoneName);
388
+ let end = toDateTimeInDate(date, params.businessHours.endTime, params.timezoneName);
389
+ if (end <= start) {
390
+ end = toDateTimeInDate(nextDate(date), params.businessHours.endTime, params.timezoneName);
391
+ }
392
+ return {
393
+ start,
394
+ end
395
+ };
313
396
  });
314
- return availableSegments.filter(segment => rangeFitsDuration(segment, durationMinutes)).map(segment => makeAvailabilityRangeCell({
315
- interval,
316
- resource,
317
- segment
397
+ const sourceWindows = params.input ? params.input.windows : fallbackWindows.map(range => ({
398
+ startAt: formatDateTime(range.start, params.timezoneName, params.fixedOffsetMinutes),
399
+ endAt: formatDateTime(range.end, params.timezoneName, params.fixedOffsetMinutes)
400
+ }));
401
+ const windows = mergeRanges(sourceWindows.map(window => normalizeAbsoluteRange(window, params.timezoneName)).filter(window => Boolean(window)).map(window => clipRange(window, params.coverage)).filter(window => Boolean(window))).map(range => ({
402
+ startAt: formatDateTime(range.start, params.timezoneName, params.fixedOffsetMinutes),
403
+ endAt: formatDateTime(range.end, params.timezoneName, params.fixedOffsetMinutes)
318
404
  }));
405
+ return {
406
+ source: params.input?.source || params.fallbackSource,
407
+ scheduleIds: uniqById(params.input?.scheduleIds || []),
408
+ missingScheduleIds: uniqById(params.input?.missingScheduleIds || []),
409
+ windows
410
+ };
319
411
  }
320
- function evaluateResourceForInterval(params) {
321
- const {
322
- resource,
323
- interval,
324
- context
325
- } = params;
326
- const start = (0, _dayjs.default)(interval.start_at);
327
- const end = (0, _dayjs.default)(interval.end_at);
328
- const conflicts = [];
329
- const reasonCodes = [];
330
- const maxCapacity = Math.max(1, Number(resource.capacity || 1));
331
- const requiredCapacity = Math.max(1, Number(interval.capacityRequired || 1));
332
- if (start.isBefore((0, _dayjs.default)())) {
333
- reasonCodes.push('past');
334
- conflicts.push({
335
- type: 'past',
336
- message: '时间已过期',
337
- resourceId: resource.id,
338
- productId: interval.productId
339
- });
412
+ function matchesRequirementGroup(requirementGroup, resource, product, partySize = 1) {
413
+ if (requirementGroup.formId !== undefined && resource.formId !== undefined && !sameId(requirementGroup.formId, resource.formId)) {
414
+ return false;
340
415
  }
341
- const insideResourceTime = resource.times.some(time => {
342
- return containsRange((0, _dayjs.default)(time.start_at), (0, _dayjs.default)(time.end_at), start, end);
416
+ if (requirementGroup.resourceIds?.length && !requirementGroup.resourceIds.some(resourceId => sameId(resourceId, resource.id))) {
417
+ return false;
418
+ }
419
+ return resource.capacity >= resolveRequiredCapacity(requirementGroup, product, partySize);
420
+ }
421
+ function resolveRequiredCapacity(requirementGroup, product, partySize) {
422
+ return Math.max(1, Number(partySize ?? requirementGroup.capacityRequired ?? product.capacityRequired ?? 1) || 1);
423
+ }
424
+ function buildCellKey(params) {
425
+ return [String(params.productId), params.groupId, String(params.resourceId), params.start, params.end].join(':');
426
+ }
427
+ function dedupeOccupancyRefKey(occupancy) {
428
+ if (occupancy.scheduleEventId !== undefined && occupancy.scheduleEventId !== null) {
429
+ return `scheduleEvent:${String(occupancy.resourceId)}:${String(occupancy.scheduleEventId)}`;
430
+ }
431
+ if (occupancy.bookingUid !== undefined && occupancy.bookingUid !== null) {
432
+ return `bookingUid:${String(occupancy.resourceId)}:${String(occupancy.bookingUid)}`;
433
+ }
434
+ if (occupancy.productUid !== undefined && occupancy.productUid !== null) {
435
+ return `productUid:${String(occupancy.resourceId)}:${String(occupancy.productUid)}`;
436
+ }
437
+ return `range:${String(occupancy.resourceId)}:${occupancy.startAt}:${occupancy.endAt}:${String(occupancy.pax || 1)}`;
438
+ }
439
+ function buildCells(projection) {
440
+ const coverage = {
441
+ start: toDateTime(projection.meta.coverage.startAt, projection.meta.timezone),
442
+ end: toDateTime(projection.meta.coverage.endAt, projection.meta.timezone)
443
+ };
444
+ const cells = [];
445
+ const timezoneName = projection.meta.timezone;
446
+ const fixedOffsetMinutes = (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)();
447
+ const projectionNow = toDateTime(projection.meta.now);
448
+ projection.products.forEach(product => {
449
+ (product.requirementGroups || []).forEach(requirementGroup => {
450
+ projection.resources.filter(resource => matchesRequirementGroup(requirementGroup, resource, product)).forEach(resource => {
451
+ const windows = normalizeResourceWindow(resource, coverage, timezoneName);
452
+ const occupancies = projection.resourceOccupancies.filter(occupancy => sameId(occupancy.resourceId, resource.id)).map(occupancy => ({
453
+ occupancy,
454
+ range: normalizeAbsoluteRange(occupancy, timezoneName)
455
+ })).filter(item => Boolean(item.range)).map(item => ({
456
+ ...item,
457
+ range: clipRange(item.range, coverage)
458
+ })).filter(item => Boolean(item.range));
459
+ const boundaries = new Set([coverage.start, coverage.end]);
460
+ windows.forEach(window => {
461
+ boundaries.add(window.start);
462
+ boundaries.add(window.end);
463
+ });
464
+ occupancies.forEach(({
465
+ range
466
+ }) => {
467
+ boundaries.add(range.start);
468
+ boundaries.add(range.end);
469
+ });
470
+ const sortedBoundaries = Array.from(boundaries.values()).sort((left, right) => left - right);
471
+ for (let index = 0; index < sortedBoundaries.length - 1; index += 1) {
472
+ const start = sortedBoundaries[index];
473
+ const end = sortedBoundaries[index + 1];
474
+ if (end <= start) continue;
475
+ const segment = {
476
+ start,
477
+ end
478
+ };
479
+ const isInsideWorkingWindow = windows.some(window => contains(window, segment));
480
+ const segmentOccupancies = isInsideWorkingWindow ? occupancies.filter(({
481
+ range
482
+ }) => overlaps(range, segment)).map(({
483
+ occupancy
484
+ }) => occupancy) : [];
485
+ const usedCapacity = segmentOccupancies.reduce((sum, occupancy) => sum + Math.max(1, Number(occupancy.pax || 1) || 1), 0);
486
+ const remainingCapacity = Math.max(0, resource.capacity - usedCapacity);
487
+ const requiredCapacity = resolveRequiredCapacity(requirementGroup, product);
488
+ const reasonCodes = Array.from(new Set(segmentOccupancies.flatMap(occupancy => {
489
+ const source = occupancy.source || 'remote';
490
+ if (source === 'selection') return ['selection_occupied'];
491
+ if (source === 'cart') return ['cart_occupied'];
492
+ return ['resource_occupied'];
493
+ })));
494
+ let status = 'available';
495
+ if (end <= projectionNow) {
496
+ status = 'past';
497
+ reasonCodes.push('past');
498
+ } else if (!isInsideWorkingWindow) {
499
+ status = 'unavailable';
500
+ reasonCodes.push('outside_resource_window');
501
+ } else if (remainingCapacity < requiredCapacity) {
502
+ status = 'full';
503
+ reasonCodes.push('capacity_full');
504
+ } else if (remainingCapacity < resource.capacity) {
505
+ status = 'limited';
506
+ }
507
+ cells.push({
508
+ key: buildCellKey({
509
+ productId: product.id,
510
+ groupId: requirementGroup.id,
511
+ resourceId: resource.id,
512
+ start,
513
+ end
514
+ }),
515
+ productId: product.id,
516
+ requirementGroupId: requirementGroup.id,
517
+ requirementFormId: requirementGroup.formId,
518
+ resourceId: resource.id,
519
+ resourceName: resource.name,
520
+ resourceType: resource.resourceType,
521
+ startAt: formatDateTime(start, timezoneName, fixedOffsetMinutes),
522
+ endAt: formatDateTime(end, timezoneName, fixedOffsetMinutes),
523
+ date: formatDate(start, timezoneName),
524
+ startTime: formatTime(start, timezoneName),
525
+ endTime: formatTime(end, timezoneName),
526
+ timezone: projection.meta.timezone,
527
+ status,
528
+ // 资源组当前统一单选;具体资源仍用 capacity 表达可承载的预约数量。
529
+ remainingCapacity,
530
+ maxCapacity: resource.capacity,
531
+ reasonCodes: Array.from(new Set(reasonCodes)),
532
+ occupancyRefs: segmentOccupancies.map(occupancy => ({
533
+ id: occupancy.id,
534
+ resourceId: occupancy.resourceId,
535
+ source: occupancy.source,
536
+ serverEventId: occupancy.serverEventId,
537
+ bookingUid: occupancy.bookingUid,
538
+ productUid: occupancy.productUid,
539
+ scheduleEventId: occupancy.scheduleEventId
540
+ }))
541
+ });
542
+ }
543
+ });
544
+ });
343
545
  });
344
- if (!insideResourceTime) {
345
- reasonCodes.push('outside_resource_time');
346
- conflicts.push({
347
- type: 'outside_resource_time',
348
- message: '所选时间不在资源可用时间内',
349
- resourceId: resource.id,
350
- productId: interval.productId
546
+ return compressCells(cells);
547
+ }
548
+ function compressCells(cells) {
549
+ const sorted = [...cells].sort((left, right) => {
550
+ const keyLeft = [String(left.productId), String(left.requirementGroupId), String(left.resourceId), left.startAt].join(':');
551
+ const keyRight = [String(right.productId), String(right.requirementGroupId), String(right.resourceId), right.startAt].join(':');
552
+ return keyLeft.localeCompare(keyRight);
553
+ });
554
+ return sorted.reduce((result, cell) => {
555
+ const last = result[result.length - 1];
556
+ if (last && sameId(last.productId, cell.productId) && sameId(last.requirementGroupId, cell.requirementGroupId) && sameId(last.resourceId, cell.resourceId) && last.status === cell.status && last.remainingCapacity === cell.remainingCapacity && last.maxCapacity === cell.maxCapacity && JSON.stringify(last.reasonCodes) === JSON.stringify(cell.reasonCodes) && JSON.stringify(last.occupancyRefs) === JSON.stringify(cell.occupancyRefs) && last.endAt === cell.startAt) {
557
+ last.endAt = cell.endAt;
558
+ last.endTime = cell.endTime;
559
+ return result;
560
+ }
561
+ result.push({
562
+ ...cell
351
563
  });
564
+ return result;
565
+ }, []);
566
+ }
567
+ function makeCoverageAbsolute(dateRange, timezoneName, fixedOffsetMinutes) {
568
+ const guardEndDate = nextDate(dateRange.endDate, timezoneName);
569
+ return {
570
+ startAt: formatDateTime(toDateStart(dateRange.startDate, timezoneName), timezoneName, fixedOffsetMinutes),
571
+ endAt: formatDateTime(toDateEnd(guardEndDate, timezoneName), timezoneName, fixedOffsetMinutes)
572
+ };
573
+ }
574
+ function normalizeProjectionInstant(value, fallbackScalar, timezoneName, fixedOffsetMinutes) {
575
+ const scalar = value ? toDateTime(value) : fallbackScalar;
576
+ return formatDateTime(Number.isFinite(scalar) ? scalar : fallbackScalar, timezoneName, fixedOffsetMinutes);
577
+ }
578
+ function cloneSnapshotMetadataValue(value) {
579
+ if (Array.isArray(value)) {
580
+ return value.map(item => cloneSnapshotMetadataValue(item));
352
581
  }
353
- const events = getEventsForResource(resource, context.externalEvents);
354
- const overlapping = events.filter(event => overlaps(start, end, (0, _dayjs.default)(event.start_at), (0, _dayjs.default)(event.end_at)));
355
- const usedCapacity = resource.resourceType === 'single' ? overlapping.length : overlapping.reduce((sum, event) => sum + Math.max(1, Number(event.pax || 1)), 0);
356
- const remainingCapacity = resource.resourceType === 'single' ? overlapping.length > 0 ? 0 : 1 : Math.max(0, maxCapacity - usedCapacity);
357
- if (overlapping.length > 0 && resource.resourceType === 'single') {
358
- reasonCodes.push('remote_event_overlap');
359
- conflicts.push(...overlapping.map(event => ({
360
- type: 'remote_event_overlap',
361
- message: '资源时间被已有预约占用',
362
- event,
363
- resourceId: resource.id,
364
- productId: interval.productId
365
- })));
366
- }
367
- if (resource.resourceType !== 'single' && remainingCapacity < requiredCapacity) {
368
- reasonCodes.push('capacity_full');
369
- conflicts.push({
370
- type: 'capacity_full',
371
- message: '资源剩余容量不足',
372
- resourceId: resource.id,
373
- productId: interval.productId
582
+ if (!value || typeof value !== 'object') return value;
583
+ return Object.keys(value).reduce((result, key) => {
584
+ result[key] = cloneSnapshotMetadataValue(value[key]);
585
+ return result;
586
+ }, {});
587
+ }
588
+ function deepFreeze(value) {
589
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
590
+ Object.freeze(value);
591
+ Object.getOwnPropertyNames(value).forEach(key => {
592
+ // `raw` is an explicit escape hatch to the backend/catalog record. It is not owned by the
593
+ // projection and must not freeze mutable ProductList / Order data shared elsewhere.
594
+ if (key === 'raw') return;
595
+ const child = value[key];
596
+ if (child && (typeof child === 'object' || typeof child === 'function')) {
597
+ deepFreeze(child);
598
+ }
599
+ });
600
+ return value;
601
+ }
602
+ function createAvailabilityProjection(input) {
603
+ const coverage = normalizeCoverage(input);
604
+ const fixedOffsetMinutes = (0, _localClock.captureRuntimeOffsetMinutes)();
605
+ const runtimeNow = (0, _localClock.localDateToScalar)(new Date());
606
+ const products = normalizeProducts(input.products || []);
607
+ const resources = normalizeResources(input.resources || []);
608
+ const resourceOccupancies = uniqByOccupancy((input.resourceOccupancies || []).map(occupancy => normalizeOccupancy(occupancy, coverage.absolute, input.timezone, fixedOffsetMinutes)).filter(occupancy => Boolean(occupancy)));
609
+ const preparedAt = normalizeProjectionInstant(input.preparedAt, runtimeNow, input.timezone, fixedOffsetMinutes);
610
+ const now = normalizeProjectionInstant(input.now, runtimeNow, input.timezone, fixedOffsetMinutes);
611
+ const durationCandidateAnchorAt = normalizeProjectionInstant(input.durationCandidateAnchorAt || now, runtimeNow, input.timezone, fixedOffsetMinutes);
612
+ const businessHours = {
613
+ startTime: input.businessHours?.startTime || DEFAULT_BUSINESS_HOURS.startTime,
614
+ endTime: input.businessHours?.endTime || DEFAULT_BUSINESS_HOURS.endTime
615
+ };
616
+ const operatingHours = normalizeOperatingHours({
617
+ input: input.operatingHours,
618
+ businessHours,
619
+ coverage: coverage.absolute,
620
+ dateRange: coverage.dateRange,
621
+ timezoneName: input.timezone,
622
+ fixedOffsetMinutes,
623
+ fallbackSource: input.businessHours ? 'prepare_business_hours' : 'default_business_hours'
624
+ });
625
+ const meta = {
626
+ snapshotId: input.snapshotId || `availability-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
627
+ preparedAt,
628
+ timezone: input.timezone,
629
+ orderRevision: input.orderRevision,
630
+ mode: input.mode || 'appointment',
631
+ coverage: {
632
+ ...coverage.dateRange,
633
+ ...makeCoverageAbsolute(coverage.dateRange, input.timezone, fixedOffsetMinutes)
634
+ },
635
+ defaults: {
636
+ slotStepMinutes: Math.max(1, Number(input.slotStepMinutes || DEFAULT_SLOT_STEP_MINUTES) || DEFAULT_SLOT_STEP_MINUTES),
637
+ businessHours: {
638
+ ...businessHours
639
+ },
640
+ operatingHours
641
+ },
642
+ now,
643
+ durationCandidateAnchorAt,
644
+ anchors: input.anchors ? cloneSnapshotMetadataValue(input.anchors) : undefined,
645
+ prepareParams: input.prepareParams ? cloneSnapshotMetadataValue(input.prepareParams) : undefined
646
+ };
647
+ const projectionBase = {
648
+ meta,
649
+ products,
650
+ resources,
651
+ resourceOccupancies
652
+ };
653
+ return deepFreeze({
654
+ ...projectionBase,
655
+ cells: buildCells(projectionBase)
656
+ });
657
+ }
658
+ function uniqByOccupancy(occupancies) {
659
+ const map = new Map();
660
+ occupancies.forEach(occupancy => {
661
+ map.set(dedupeOccupancyRefKey(occupancy), occupancy);
662
+ });
663
+ return Array.from(map.values());
664
+ }
665
+ function resolveProjectionDateRange(projection, requestDateRange) {
666
+ const startDate = requestDateRange?.startDate || projection.meta.coverage.startDate;
667
+ const endDate = requestDateRange?.endDate || projection.meta.coverage.endDate;
668
+ const isCanonicalDate = value => {
669
+ try {
670
+ return (0, _localClock.formatLocalDate)((0, _localClock.parseLocalDate)(value)) === value;
671
+ } catch (_error) {
672
+ return false;
673
+ }
674
+ };
675
+ if (!isCanonicalDate(startDate) || !isCanonicalDate(endDate) || endDate < startDate) {
676
+ throw availabilityError('INVALID_DATE_RANGE', '查询日期范围必须是有效的 YYYY-MM-DD,且结束日期不能早于开始日期', {
677
+ requested: requestDateRange
374
678
  });
375
679
  }
376
- let status = 'available';
377
- if (reasonCodes.includes('past')) status = 'past';else if (reasonCodes.includes('outside_resource_time')) status = 'unavailable';else if (reasonCodes.includes('remote_event_overlap') || reasonCodes.includes('capacity_full')) {
378
- status = 'full';
379
- } else if (remainingCapacity < maxCapacity) {
380
- status = 'limited';
680
+ if (startDate < projection.meta.coverage.startDate || endDate > projection.meta.coverage.endDate) {
681
+ throw availabilityError('OUT_OF_PROJECTION_COVERAGE', '查询范围超出 projection.coverage', {
682
+ requested: requestDateRange,
683
+ coverage: projection.meta.coverage
684
+ });
381
685
  }
382
686
  return {
383
- key: `${interval.productId}:${resource.id}:${interval.start_at}:${interval.end_at}`,
384
- productId: interval.productId,
385
- resourceId: resource.id,
386
- date: interval.date,
387
- start_at: interval.start_at,
388
- end_at: interval.end_at,
389
- startTime: timeOf(interval.start_at),
390
- endTime: timeOf(interval.end_at),
391
- durationMinutes: interval.durationMinutes,
392
- cellType: status === 'available' || status === 'limited' ? 'fixed_interval' : 'unavailable',
393
- status,
394
- remainingCapacity,
395
- maxCapacity,
396
- reasonCodes,
397
- conflicts
687
+ startDate,
688
+ endDate
398
689
  };
399
690
  }
400
- function aggregateStatus(cells) {
401
- if (!cells.length) return 'unavailable';
402
- if (cells.some(cell => cell.status === 'available')) return 'available';
403
- if (cells.some(cell => cell.status === 'limited')) return 'limited';
404
- if (cells.some(cell => cell.status === 'past')) return 'past';
405
- if (cells.some(cell => cell.status === 'full')) return 'full';
406
- if (cells.some(cell => cell.status === 'conflict')) return 'conflict';
407
- return 'unavailable';
691
+ function buildOperatingRanges(projection, dateRange) {
692
+ const coverage = {
693
+ start: toDateStart(dateRange.startDate, projection.meta.timezone),
694
+ end: toDateEnd(nextDate(dateRange.endDate), projection.meta.timezone)
695
+ };
696
+ return mergeRanges(projection.meta.defaults.operatingHours.windows.map(window => normalizeAbsoluteRange(window, projection.meta.timezone)).filter(window => Boolean(window)).map(window => clipRange(window, coverage)).filter(window => Boolean(window)));
408
697
  }
409
- function uniqueReasonCodes(cells) {
410
- return Array.from(new Set(cells.flatMap(cell => cell.reasonCodes || [])));
698
+ function buildResourceWindowIndex(projection) {
699
+ const coverage = {
700
+ start: toDateTime(projection.meta.coverage.startAt, projection.meta.timezone),
701
+ end: toDateTime(projection.meta.coverage.endAt, projection.meta.timezone)
702
+ };
703
+ return new Map(projection.resources.map(resource => [String(resource.id), normalizeResourceWindow(resource, coverage, projection.meta.timezone)]));
411
704
  }
412
- function buildProductOptions(products, cells, query) {
413
- const queryProducts = getQueryProducts(products, query);
414
- return queryProducts.map(product => {
415
- const productCells = cells.filter(cell => sameId(cell.productId, product.id));
416
- const status = product.kind === 'normal' ? 'available' : aggregateStatus(productCells);
417
- return {
418
- productId: product.id,
419
- title: resolveProductTitle(product),
420
- status,
421
- reasonCodes: uniqueReasonCodes(productCells)
705
+ function buildGroupWorkingRanges(params) {
706
+ const {
707
+ projection,
708
+ requirementGroup,
709
+ dateRange,
710
+ resourceIds,
711
+ partySize
712
+ } = params;
713
+ const guardEndDate = nextDate(dateRange.endDate, projection.meta.timezone);
714
+ const coverage = {
715
+ start: toDateStart(dateRange.startDate, projection.meta.timezone),
716
+ end: toDateEnd(guardEndDate, projection.meta.timezone)
717
+ };
718
+ const filteredResourceIdSet = resourceIds?.length ? new Set(resourceIds.map(String)) : null;
719
+ const resourceWindows = projection.resources.filter(resource => matchesRequirementGroup(requirementGroup, resource, params.product, partySize)).filter(resource => !filteredResourceIdSet || filteredResourceIdSet.has(String(resource.id))).map(resource => {
720
+ const windows = params.resourceWindowIndex?.get(String(resource.id)) || normalizeResourceWindow(resource, coverage, projection.meta.timezone);
721
+ return windows.map(window => clipRange(window, coverage)).filter(window => Boolean(window));
722
+ });
723
+ const requiredConcurrentResources = Math.max(requirementGroup.required === false ? 0 : 1, Number(requirementGroup.min ?? (requirementGroup.required === false ? 0 : 1)) || 0);
724
+ if (requiredConcurrentResources <= 1) {
725
+ return mergeRanges(resourceWindows.flatMap(windows => windows));
726
+ }
727
+ const boundaries = new Set();
728
+ resourceWindows.forEach(windows => {
729
+ windows.forEach(window => {
730
+ boundaries.add(window.start);
731
+ boundaries.add(window.end);
732
+ });
733
+ });
734
+ const sortedBoundaries = Array.from(boundaries.values()).sort((left, right) => left - right);
735
+ const satisfiedRanges = [];
736
+ for (let index = 0; index < sortedBoundaries.length - 1; index += 1) {
737
+ const start = sortedBoundaries[index];
738
+ const end = sortedBoundaries[index + 1];
739
+ if (end <= start) continue;
740
+ const segment = {
741
+ start,
742
+ end
422
743
  };
744
+ const activeResources = resourceWindows.reduce((count, windows) => windows.some(window => contains(window, segment)) ? count + 1 : count, 0);
745
+ if (activeResources >= requiredConcurrentResources) {
746
+ satisfiedRanges.push(segment);
747
+ }
748
+ }
749
+ return mergeRanges(satisfiedRanges);
750
+ }
751
+ function materializeScheduleRules(rules, dateRange, timezoneName, fixedOffsetMinutes) {
752
+ const dates = enumerateDates(dateRange.startDate, dateRange.endDate);
753
+ const seen = new Map();
754
+ rules.forEach(rule => {
755
+ dates.forEach(date => {
756
+ if (!matchesScheduleRuleDate(rule, date, timezoneName)) return;
757
+ const start = toDateTimeInDate(date, rule.startTime, timezoneName);
758
+ let end = toDateTimeInDate(date, rule.endTime, timezoneName);
759
+ if (end <= start) end = toDateTimeInDate(nextDate(date, timezoneName), rule.endTime, timezoneName);
760
+ const key = `${date}:${start}:${end}`;
761
+ if (!seen.has(key)) {
762
+ seen.set(key, {
763
+ key,
764
+ productId: '',
765
+ source: 'session_schedule',
766
+ startAt: formatDateTime(start, timezoneName, fixedOffsetMinutes),
767
+ endAt: formatDateTime(end, timezoneName, fixedOffsetMinutes),
768
+ bookingStartAt: formatDateTime(start, timezoneName, fixedOffsetMinutes),
769
+ bookingEndAt: formatDateTime(end, timezoneName, fixedOffsetMinutes),
770
+ date: formatDate(start, timezoneName),
771
+ startTime: formatTime(start, timezoneName),
772
+ endTime: formatTime(end, timezoneName),
773
+ timezone: timezoneName,
774
+ scheduleRefs: []
775
+ });
776
+ }
777
+ const current = seen.get(key);
778
+ const scheduleRef = {
779
+ ...(rule.scheduleRef || {}),
780
+ ruleId: rule.id
781
+ };
782
+ current.scheduleRefs = mergeScheduleRefs(current.scheduleRefs, [scheduleRef]);
783
+ if (!current.primaryScheduleRef) current.primaryScheduleRef = scheduleRef;
784
+ });
423
785
  });
786
+ return Array.from(seen.values());
424
787
  }
425
- function buildResourceOptions(resources, cells, query) {
426
- const queryResources = getQueryResources(resources, query);
427
- return queryResources.map(resource => {
428
- const resourceCells = cells.filter(cell => cell.resourceId !== undefined && sameId(cell.resourceId, resource.id));
429
- const bestCell = resourceCells.find(cell => cell.status === 'available') || resourceCells.find(cell => cell.status === 'limited') || resourceCells[0];
430
- return {
431
- resourceId: resource.id,
432
- resourceName: resource.name,
433
- resourceFormId: resource.formId,
434
- resourceType: resource.resourceType,
435
- status: resourceCells.length ? aggregateStatus(resourceCells) : 'unavailable',
436
- remainingCapacity: bestCell?.remainingCapacity,
437
- maxCapacity: bestCell?.maxCapacity || resource.capacity,
438
- reasonCodes: uniqueReasonCodes(resourceCells)
439
- };
788
+ function matchesScheduleRuleDate(rule, date, _timezoneName) {
789
+ if (rule.includeDates?.includes(date)) return true;
790
+ if (rule.excludeDates?.includes(date)) return false;
791
+ if (rule.startDate && date < rule.startDate) return false;
792
+ if (rule.endDate && date > rule.endDate) return false;
793
+ if (rule.mode === 'designation') {
794
+ if (rule.dates?.includes(date)) return true;
795
+ return false;
796
+ }
797
+ if (rule.mode === 'weekly') {
798
+ if (!rule.weekdays?.length) return true;
799
+ const freq = Math.max(1, Number(rule.frequency || 1) || 1);
800
+ if (!rule.weekdays.includes((0, _localClock.weekdayFromLocalDate)(date))) return false;
801
+ if (!rule.startDate) return true;
802
+ const targetWeekStart = (0, _localClock.dateOrdinalFromLocalDate)(date) - (0, _localClock.weekdayFromLocalDate)(date);
803
+ const ruleWeekStart = (0, _localClock.dateOrdinalFromLocalDate)(rule.startDate) - (0, _localClock.weekdayFromLocalDate)(rule.startDate);
804
+ return Math.floor((targetWeekStart - ruleWeekStart) / 7) % freq === 0;
805
+ }
806
+ if (rule.mode === 'daily' && rule.startDate) {
807
+ const freq = Math.max(1, Number(rule.frequency || 1) || 1);
808
+ return ((0, _localClock.dateOrdinalFromLocalDate)(date) - (0, _localClock.dateOrdinalFromLocalDate)(rule.startDate)) % freq === 0;
809
+ }
810
+ return true;
811
+ }
812
+ function mergeScheduleRefs(base, next) {
813
+ const map = new Map();
814
+ [...base, ...next].forEach(ref => {
815
+ const key = [ref.scheduleId ?? '', ref.timeSlotId ?? '', ref.ruleId ?? '', ref.source ?? '', ref.label ?? ''].join(':');
816
+ map.set(key, ref);
817
+ });
818
+ return Array.from(map.values());
819
+ }
820
+ function buildProductIndex(projection) {
821
+ return new Map(projection.products.map(product => [String(product.id), product]));
822
+ }
823
+ function buildGroupResourceIndex(params) {
824
+ const resourceIdSet = params.resourceIds?.length ? new Set(params.resourceIds.map(String)) : null;
825
+ const index = new Map();
826
+ params.projection.products.forEach(product => {
827
+ (product.requirementGroups || []).forEach(requirementGroup => {
828
+ const key = [String(product.id), requirementGroup.id].join(':');
829
+ index.set(key, params.projection.resources.filter(resource => matchesRequirementGroup(requirementGroup, resource, product, params.partySize)).filter(resource => !resourceIdSet || resourceIdSet.has(String(resource.id))));
830
+ });
440
831
  });
832
+ return index;
441
833
  }
442
- function buildDateSummary(cells, query) {
443
- const dates = query.date ? [query.date] : query.dateRange ? addDays(query.dateRange.start, query.dateRange.end) : Array.from(new Set(cells.map(cell => cell.date).filter(Boolean)));
444
- return dates.map(date => {
445
- const dateCells = cells.filter(cell => cell.date === date);
446
- const availableCount = dateCells.filter(cell => cell.status === 'available' || cell.status === 'limited').length;
834
+ function getSessionRangesForProduct(projection, product, dateRange) {
835
+ const fixedOffsetMinutes = (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)();
836
+ const ranges = (product.sessionRanges || []).map(range => {
837
+ const normalized = normalizeAbsoluteRange(range, projection.meta.timezone);
838
+ if (!normalized) return null;
839
+ const startDate = formatDate(normalized.start, projection.meta.timezone);
840
+ if (startDate < dateRange.startDate || startDate > dateRange.endDate) {
841
+ return null;
842
+ }
447
843
  return {
448
- date,
449
- status: dateCells.length ? aggregateStatus(dateCells) : 'unavailable',
450
- availableCount,
451
- totalCount: dateCells.length
844
+ key: `${String(product.id)}:${normalized.start}:${normalized.end}`,
845
+ productId: product.id,
846
+ source: 'session_schedule',
847
+ startAt: formatDateTime(normalized.start, projection.meta.timezone, fixedOffsetMinutes),
848
+ endAt: formatDateTime(normalized.end, projection.meta.timezone, fixedOffsetMinutes),
849
+ bookingStartAt: formatDateTime(normalized.start, projection.meta.timezone, fixedOffsetMinutes),
850
+ bookingEndAt: formatDateTime(normalized.end, projection.meta.timezone, fixedOffsetMinutes),
851
+ date: formatDate(normalized.start, projection.meta.timezone),
852
+ startTime: formatTime(normalized.start, projection.meta.timezone),
853
+ endTime: formatTime(normalized.end, projection.meta.timezone),
854
+ timezone: projection.meta.timezone,
855
+ scheduleRefs: range.scheduleRefs || [],
856
+ primaryScheduleRef: range.scheduleRefs?.[0]
452
857
  };
858
+ }).filter(Boolean);
859
+ const ruleRanges = materializeScheduleRules(product.sessionRules || [], dateRange, projection.meta.timezone, fixedOffsetMinutes);
860
+ return dedupeProductRanges([...ranges, ...ruleRanges.map(range => ({
861
+ ...range,
862
+ key: `${String(product.id)}:${range.startAt}:${range.endAt}`,
863
+ productId: product.id,
864
+ timezone: projection.meta.timezone
865
+ }))]);
866
+ }
867
+ function dedupeProductRanges(ranges) {
868
+ const map = new Map();
869
+ ranges.forEach(range => {
870
+ const key = getProductTimeRangeIdentity(range);
871
+ if (!map.has(key)) {
872
+ map.set(key, {
873
+ ...range,
874
+ key
875
+ });
876
+ return;
877
+ }
878
+ const existing = map.get(key);
879
+ existing.scheduleRefs = mergeScheduleRefs(existing.scheduleRefs, range.scheduleRefs || []);
880
+ if (!existing.primaryScheduleRef) existing.primaryScheduleRef = range.primaryScheduleRef;
453
881
  });
882
+ return Array.from(map.values()).sort((left, right) => left.startAt.localeCompare(right.startAt));
454
883
  }
455
- function buildTimeLabels(date, context, query) {
456
- if (!date) return [];
457
- const slotMinutes = query.slotDurationMinutes || context.slotDurationMinutes;
458
- const labels = [];
459
- let cursor = toDateTime(date, context.businessStartTime);
460
- let end = toDateTime(date, context.businessEndTime);
461
- if (!end.isAfter(cursor)) end = end.add(1, 'day');
462
- while (cursor.isBefore(end)) {
463
- labels.push(cursor.format('HH:mm'));
464
- cursor = cursor.add(slotMinutes, 'minute');
465
- }
466
- return labels;
884
+ function createDurationTimeRange(product, start, durationMinutes, projection, fixedOffsetMinutes) {
885
+ const bookingEnd = addMinutesZoned(start, durationMinutes, projection.meta.timezone);
886
+ return createDurationTimeRangeWithEnd(product, start, bookingEnd, projection, fixedOffsetMinutes);
467
887
  }
468
- function buildGridSlotInterval(product, date, label, context, query) {
469
- const slotMinutes = product.kind === 'duration' ? Math.max(1, Number(product.durationMinutes || query.slotDurationMinutes || context.slotDurationMinutes)) : query.slotDurationMinutes || context.slotDurationMinutes;
470
- const start = toDateTime(date, label);
471
- const end = start.add(slotMinutes, 'minute');
888
+ function createDurationTimeRangeWithEnd(product, start, bookingEnd, projection, fixedOffsetMinutes) {
472
889
  return {
473
- id: `${product.id}:${date}:${label}`,
890
+ key: `${String(product.id)}:${start}:${bookingEnd}`,
474
891
  productId: product.id,
475
- source: product.kind === 'session' ? 'session' : product.kind === 'venue_slot' ? 'venue_slot' : 'duration',
476
- date,
477
- start_at: start.toISOString(),
478
- end_at: end.toISOString(),
479
- durationMinutes: slotMinutes,
480
- quantity: Math.max(1, Number(product.quantity || 1)),
481
- capacityRequired: Math.max(1, Number(query.pax || product.capacityRequired || 1)),
482
- resourceRequirements: product.resourceRequirements || []
483
- };
484
- }
485
- function buildTimeGrid(context, query, cells) {
486
- const date = query.date || query.dateRange?.start;
487
- const products = getQueryProducts(context.products, query).filter(product => product.kind !== 'normal');
488
- const resources = getQueryResources(context.resources, query);
489
- const timeLabels = buildTimeLabels(date, context, query);
490
- if (!date || !products.length || !resources.length) {
491
- return {
492
- date,
493
- timeLabels,
494
- resources: []
495
- };
496
- }
497
- const rows = resources.map(resource => {
498
- const slots = timeLabels.map(label => {
499
- const product = products[0];
500
- const interval = buildGridSlotInterval(product, date, label, context, query);
501
- const start = (0, _dayjs.default)(interval.start_at);
502
- const end = (0, _dayjs.default)(interval.end_at);
503
- const cell = cells.find(item => sameId(item.productId, product.id) && sameId(item.resourceId, resource.id) && item.date === date && (item.status === 'available' || item.status === 'limited') && containsRange((0, _dayjs.default)(item.start_at), (0, _dayjs.default)(item.end_at), start, end));
892
+ source: 'duration_window',
893
+ startAt: formatDateTime(start, projection.meta.timezone, fixedOffsetMinutes),
894
+ endAt: formatDateTime(bookingEnd, projection.meta.timezone, fixedOffsetMinutes),
895
+ bookingStartAt: formatDateTime(start, projection.meta.timezone, fixedOffsetMinutes),
896
+ bookingEndAt: formatDateTime(bookingEnd, projection.meta.timezone, fixedOffsetMinutes),
897
+ date: formatDate(start, projection.meta.timezone),
898
+ startTime: formatTime(start, projection.meta.timezone),
899
+ endTime: formatTime(bookingEnd, projection.meta.timezone),
900
+ timezone: projection.meta.timezone,
901
+ scheduleRefs: []
902
+ };
903
+ }
904
+ function getProductTimeRanges(projection, request = {}, runtimeOptions = {}) {
905
+ const normalizedPartySize = normalizePartySize(request.partySize);
906
+ const dateRange = resolveProjectionDateRange(projection, request.dateRange);
907
+ const slotStepMinutes = projection.meta.defaults.slotStepMinutes;
908
+ const fixedOffsetMinutes = (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)();
909
+ const resourceWindowIndex = buildResourceWindowIndex(projection);
910
+ const filteredProductIdSet = request.productIds?.length ? new Set(request.productIds.map(String)) : null;
911
+ return projection.products.filter(product => !filteredProductIdSet || filteredProductIdSet.has(String(product.id))).map(product => {
912
+ if (product.kind === 'normal') {
504
913
  return {
505
- startTime: label,
506
- endTime: (0, _dayjs.default)(interval.end_at).format('HH:mm'),
507
- status: cell?.status || 'unavailable',
508
- resourceId: resource.id,
509
914
  productId: product.id,
510
- remainingCapacity: cell?.remainingCapacity,
511
- maxCapacity: cell?.maxCapacity || resource.capacity,
512
- reasonCodes: cell?.reasonCodes || ['outside_available_range']
915
+ title: product.title,
916
+ kind: product.kind,
917
+ slotStepMinutes,
918
+ ranges: []
513
919
  };
920
+ }
921
+ if (product.kind === 'session' || product.kind === 'venue_slot') {
922
+ return {
923
+ productId: product.id,
924
+ title: product.title,
925
+ kind: product.kind,
926
+ slotStepMinutes,
927
+ ranges: getSessionRangesForProduct(projection, product, dateRange)
928
+ };
929
+ }
930
+ const requiredGroups = (product.requirementGroups || []).filter(group => group.required !== false);
931
+ const operatingRanges = buildOperatingRanges(projection, dateRange);
932
+ const resourceRanges = requiredGroups.length ? requiredGroups.map(group => buildGroupWorkingRanges({
933
+ projection,
934
+ product,
935
+ requirementGroup: group,
936
+ dateRange,
937
+ resourceIds: request.resourceIds,
938
+ partySize: normalizedPartySize,
939
+ resourceWindowIndex
940
+ })).reduce((current, ranges) => current === null ? ranges : intersectRangeLists(current, ranges), null) || [] : null;
941
+ const baseRanges = resourceRanges === null ? operatingRanges : intersectRangeLists(resourceRanges, operatingRanges);
942
+ const durationMinutes = resolveRequestedDurationMinutes(product, request);
943
+ const flexibleStartAt = (() => {
944
+ if (!product.isFlexibleDuration) return undefined;
945
+ const evaluatedAt = toDateTime(runtimeOptions.evaluatedAt || projection.meta.durationCandidateAnchorAt, projection.meta.timezone);
946
+ if (!Number.isFinite(evaluatedAt)) {
947
+ throw availabilityError('INVALID_TARGET_FILTER', 'runtimeOptions.evaluatedAt 必须是有效时间', {
948
+ evaluatedAt: runtimeOptions.evaluatedAt
949
+ });
950
+ }
951
+ return floorToMinute(evaluatedAt);
952
+ })();
953
+ const durationAnchor = ceilToTenMinutes(toDateTime(projection.meta.durationCandidateAnchorAt, projection.meta.timezone), projection.meta.timezone);
954
+ const requestedStartTimes = resolveRequestedDurationStartTimes(product, request);
955
+ const ranges = [];
956
+ baseRanges.forEach(range => {
957
+ if (product.isFlexibleDuration && flexibleStartAt !== undefined) {
958
+ const start = Math.max(range.start, flexibleStartAt);
959
+ if (start >= range.end) return;
960
+ const bookingDate = formatDate(start);
961
+ if (bookingDate >= dateRange.startDate && bookingDate <= dateRange.endDate) {
962
+ ranges.push(createDurationTimeRangeWithEnd(product, start, range.end, projection, fixedOffsetMinutes));
963
+ }
964
+ return;
965
+ }
966
+ if (range.end <= durationAnchor) return;
967
+ let cursor = range.start;
968
+ if (cursor < durationAnchor) {
969
+ cursor = durationAnchor;
970
+ }
971
+ while (true) {
972
+ const bookingEnd = addMinutesZoned(cursor, durationMinutes, projection.meta.timezone);
973
+ if (bookingEnd > range.end) break;
974
+ const bookingDate = formatDate(cursor);
975
+ if (bookingDate >= dateRange.startDate && bookingDate <= dateRange.endDate) {
976
+ ranges.push(createDurationTimeRange(product, cursor, durationMinutes, projection, fixedOffsetMinutes));
977
+ }
978
+ cursor = addMinutesZoned(cursor, slotStepMinutes, projection.meta.timezone);
979
+ }
514
980
  });
981
+ if (dateRange.startDate === dateRange.endDate && requestedStartTimes.length > 0) {
982
+ requestedStartTimes.forEach(startTime => {
983
+ const start = toDateTimeInDate(dateRange.startDate, startTime, projection.meta.timezone);
984
+ if (!Number.isFinite(start)) return;
985
+ const end = addMinutesZoned(start, durationMinutes, projection.meta.timezone);
986
+ const startsInOperatingRange = operatingRanges.some(range => range.start <= start && start < range.end);
987
+ if (!startsInOperatingRange && !request.allowOutsideOperatingHoursForRequestedStartTimes) {
988
+ return;
989
+ }
990
+ ranges.push(createDurationTimeRange(product, start, durationMinutes, projection, fixedOffsetMinutes));
991
+ });
992
+ }
515
993
  return {
516
- resourceId: resource.id,
517
- resourceName: resource.name,
518
- resourceFormId: resource.formId,
519
- slots
994
+ productId: product.id,
995
+ title: product.title,
996
+ kind: product.kind,
997
+ slotStepMinutes,
998
+ ranges: dedupeProductRanges(ranges)
520
999
  };
521
1000
  });
1001
+ }
1002
+ function aggregateMatchedCells(productId, requirementGroupId, resourceId, candidateRange, indexedCells) {
1003
+ const candidateStartMs = candidateRange.start;
1004
+ const candidateEndMs = candidateRange.end;
1005
+ let left = 0;
1006
+ let right = indexedCells.length;
1007
+ while (left < right) {
1008
+ const middle = Math.floor((left + right) / 2);
1009
+ if (indexedCells[middle].range.endMs <= candidateStartMs) {
1010
+ left = middle + 1;
1011
+ } else {
1012
+ right = middle;
1013
+ }
1014
+ }
1015
+ const overlapped = [];
1016
+ for (let index = Math.max(0, left - 1); index < indexedCells.length; index += 1) {
1017
+ const {
1018
+ cell,
1019
+ range
1020
+ } = indexedCells[index];
1021
+ if (range.startMs >= candidateEndMs) break;
1022
+ if (range.endMs <= candidateStartMs) continue;
1023
+ const clipped = clipRange(range, candidateRange);
1024
+ if (clipped) overlapped.push({
1025
+ cell,
1026
+ range: clipped
1027
+ });
1028
+ }
1029
+ if (!overlapped.length) return undefined;
1030
+ let cursor = candidateRange.start;
1031
+ for (const item of overlapped) {
1032
+ if (item.range.start > cursor) return undefined;
1033
+ if (item.range.end > cursor) {
1034
+ cursor = item.range.end;
1035
+ }
1036
+ if (cursor >= candidateRange.end) break;
1037
+ }
1038
+ if (cursor < candidateRange.end) return undefined;
1039
+ const cells = overlapped.map(item => item.cell);
1040
+ const statuses = cells.map(cell => cell.status);
1041
+ const nonPastStatuses = statuses.filter(status => status !== 'past');
1042
+ const aggregatedStatus = (() => {
1043
+ if (nonPastStatuses.some(status => status === 'unavailable')) return 'unavailable';
1044
+ if (nonPastStatuses.some(status => status === 'full')) return 'full';
1045
+ if (nonPastStatuses.some(status => status === 'limited')) return 'limited';
1046
+ if (nonPastStatuses.every(status => status === 'available')) return 'available';
1047
+ if (statuses.every(status => status === 'past')) return 'past';
1048
+ return 'available';
1049
+ })();
1050
+ const capacityCells = cells.filter(cell => cell.remainingCapacity !== undefined || cell.maxCapacity !== undefined);
1051
+ const reasonCodes = Array.from(new Set(cells.flatMap(cell => cell.reasonCodes)));
1052
+ const occupancyRefs = Array.from(new Map(cells.flatMap(cell => cell.occupancyRefs || []).map(ref => [[ref.id ?? '', ref.resourceId ?? '', ref.serverEventId ?? '', ref.bookingUid ?? '', ref.productUid ?? '', ref.scheduleEventId ?? '', ref.source ?? ''].join(':'), ref])).values());
1053
+ const firstCell = cells[0];
522
1054
  return {
523
- date,
524
- timeLabels,
525
- resources: rows
1055
+ ...firstCell,
1056
+ key: buildCellKey({
1057
+ productId,
1058
+ groupId: requirementGroupId,
1059
+ resourceId,
1060
+ start: candidateRange.start,
1061
+ end: candidateRange.end
1062
+ }),
1063
+ startAt: formatDateTime(candidateRange.start, firstCell.timezone, (0, _localClock.extractFixedOffsetMinutes)(firstCell.startAt) ?? (0, _localClock.captureRuntimeOffsetMinutes)()),
1064
+ endAt: formatDateTime(candidateRange.end, firstCell.timezone, (0, _localClock.extractFixedOffsetMinutes)(firstCell.startAt) ?? (0, _localClock.captureRuntimeOffsetMinutes)()),
1065
+ date: formatDate(candidateRange.start, firstCell.timezone),
1066
+ startTime: formatTime(candidateRange.start, firstCell.timezone),
1067
+ endTime: formatTime(candidateRange.end, firstCell.timezone),
1068
+ status: aggregatedStatus,
1069
+ remainingCapacity: capacityCells.length ? Math.min(...capacityCells.map(cell => cell.remainingCapacity ?? Number.MAX_SAFE_INTEGER)) : undefined,
1070
+ maxCapacity: capacityCells.length ? Math.min(...capacityCells.map(cell => cell.maxCapacity ?? Number.MAX_SAFE_INTEGER)) : undefined,
1071
+ reasonCodes,
1072
+ occupancyRefs
526
1073
  };
527
1074
  }
528
- function queryPlannerAvailability(contextInput, query = {}) {
529
- const context = normalizePlannerContext(contextInput);
530
- const intervals = buildDemandIntervals(context.products, query, context);
531
- const resources = getQueryResources(context.resources, query);
532
- const eventsByResource = collectEventsByResource(resources, context.externalEvents);
533
- const cells = [];
534
- for (const interval of intervals) {
535
- const candidateResources = resources.filter(resource => {
536
- const requirements = interval.resourceRequirements || [];
537
- if (!requirements.length) return true;
538
- return requirements.some(requirement => matchesResourceRequirement(resource, requirement));
539
- });
540
- if (!candidateResources.length) {
541
- cells.push({
542
- key: `${interval.productId}:missing-resource:${interval.start_at}`,
543
- productId: interval.productId,
544
- date: interval.date,
545
- start_at: interval.start_at,
546
- end_at: interval.end_at,
547
- startTime: timeOf(interval.start_at),
548
- endTime: timeOf(interval.end_at),
549
- durationMinutes: interval.durationMinutes,
550
- cellType: 'unavailable',
551
- status: 'unavailable',
552
- reasonCodes: ['missing_resource'],
553
- conflicts: [{
554
- type: 'missing_resource',
555
- message: '没有匹配商品需求的资源',
556
- productId: interval.productId
557
- }]
558
- });
559
- continue;
1075
+ function aggregateMatchedCellSummary(candidateRange, indexedCells) {
1076
+ const candidateStartMs = candidateRange.startMs;
1077
+ const candidateEndMs = candidateRange.endMs;
1078
+ let left = 0;
1079
+ let right = indexedCells.length;
1080
+ while (left < right) {
1081
+ const middle = Math.floor((left + right) / 2);
1082
+ if (indexedCells[middle].range.endMs <= candidateStartMs) {
1083
+ left = middle + 1;
1084
+ } else {
1085
+ right = middle;
560
1086
  }
561
- candidateResources.forEach(resource => {
562
- if (interval.source === 'duration' && !query.startTime) {
563
- cells.push(...buildAvailabilityCellsForResource({
564
- resource,
565
- interval,
566
- events: eventsByResource.get(String(resource.id)) || []
567
- }));
568
- return;
569
- }
570
- cells.push(evaluateResourceForInterval({
571
- resource,
572
- interval,
573
- context
574
- }));
575
- });
576
1087
  }
1088
+ const cells = [];
1089
+ let cursorMs = candidateStartMs;
1090
+ for (let index = Math.max(0, left - 1); index < indexedCells.length; index += 1) {
1091
+ const {
1092
+ cell,
1093
+ range
1094
+ } = indexedCells[index];
1095
+ if (range.startMs >= candidateEndMs) break;
1096
+ if (range.endMs <= candidateStartMs) continue;
1097
+ const clippedStartMs = Math.max(range.startMs, candidateStartMs);
1098
+ const clippedEndMs = Math.min(range.endMs, candidateEndMs);
1099
+ if (clippedEndMs <= clippedStartMs) continue;
1100
+ if (clippedStartMs > cursorMs) return undefined;
1101
+ cells.push(cell);
1102
+ if (clippedEndMs > cursorMs) cursorMs = clippedEndMs;
1103
+ if (cursorMs >= candidateEndMs) break;
1104
+ }
1105
+ if (!cells.length || cursorMs < candidateEndMs) return undefined;
1106
+ const statuses = cells.map(cell => cell.status);
1107
+ const nonPastStatuses = statuses.filter(status => status !== 'past');
1108
+ const status = (() => {
1109
+ if (nonPastStatuses.some(cellStatus => cellStatus === 'unavailable')) return 'unavailable';
1110
+ if (nonPastStatuses.some(cellStatus => cellStatus === 'full')) return 'full';
1111
+ if (nonPastStatuses.some(cellStatus => cellStatus === 'limited')) return 'limited';
1112
+ if (nonPastStatuses.every(cellStatus => cellStatus === 'available')) return 'available';
1113
+ if (statuses.every(cellStatus => cellStatus === 'past')) return 'past';
1114
+ return 'available';
1115
+ })();
1116
+ const capacityCells = cells.filter(cell => cell.remainingCapacity !== undefined || cell.maxCapacity !== undefined);
577
1117
  return {
578
- query,
579
- demandIntervals: intervals,
580
- productOptions: buildProductOptions(context.products, cells, query),
581
- resourceOptions: buildResourceOptions(context.resources, cells, query),
582
- dateSummary: buildDateSummary(cells, query),
583
- timeGrid: buildTimeGrid(context, query, cells),
584
- cells
585
- };
586
- }
587
- function getIntervalRequirements(interval) {
588
- return interval.resourceRequirements.length ? interval.resourceRequirements : [{
589
- capacityRequired: interval.capacityRequired,
590
- required: true
591
- }];
592
- }
593
- function buildAssignableCandidateGroups(params) {
594
- const {
595
- interval,
596
- cells,
597
- resources
598
- } = params;
599
- const conflicts = [];
600
- const groups = getIntervalRequirements(interval).map(requirement => {
601
- const candidateCells = cells.filter(cell => {
602
- if (!sameId(cell.productId, interval.productId)) return false;
603
- if (cell.date !== interval.date) return false;
604
- if (cell.resourceId === undefined) return false;
605
- if (cell.status !== 'available' && cell.status !== 'limited') return false;
606
- const cellStart = (0, _dayjs.default)(cell.start_at);
607
- const cellEnd = (0, _dayjs.default)(cell.end_at);
608
- const intervalStart = (0, _dayjs.default)(interval.start_at);
609
- const intervalEnd = (0, _dayjs.default)(interval.end_at);
610
- return containsRange(cellStart, cellEnd, intervalStart, intervalEnd) || interval.source === 'duration';
611
- });
612
- return candidateCells.flatMap(cell => {
613
- const resource = resources.find(item => sameId(item.id, cell.resourceId));
614
- if (!resource || !matchesResourceRequirement(resource, requirement)) return [];
615
- return [{
616
- requirement,
617
- resource,
618
- cell
619
- }];
1118
+ status,
1119
+ remainingCapacity: capacityCells.length ? Math.min(...capacityCells.map(cell => cell.remainingCapacity ?? Number.MAX_SAFE_INTEGER)) : undefined,
1120
+ maxCapacity: capacityCells.length ? Math.min(...capacityCells.map(cell => cell.maxCapacity ?? Number.MAX_SAFE_INTEGER)) : undefined,
1121
+ reasonCodes: Array.from(new Set(cells.flatMap(cell => cell.reasonCodes)))
1122
+ };
1123
+ }
1124
+ function buildAvailabilityCellIndex(projection) {
1125
+ const index = new Map();
1126
+ projection.cells.forEach(cell => {
1127
+ if (cell.requirementGroupId == null || cell.resourceId == null) return;
1128
+ const key = [String(cell.productId), cell.requirementGroupId, String(cell.resourceId)].join(':');
1129
+ const entries = index.get(key) || [];
1130
+ entries.push({
1131
+ cell,
1132
+ range: toIndexedRange({
1133
+ start: toDateTime(cell.startAt, projection.meta.timezone),
1134
+ end: toDateTime(cell.endAt, projection.meta.timezone)
1135
+ })
620
1136
  });
1137
+ index.set(key, entries);
621
1138
  });
622
- groups.forEach((group, index) => {
623
- if (group.length) return;
624
- conflicts.push({
625
- type: 'missing_resource',
626
- message: '没有匹配商品需求的可用资源',
627
- productId: interval.productId,
628
- resourceId: interval.resourceRequirements[index]?.resourceIds?.[0]
629
- });
1139
+ index.forEach((entries, key) => {
1140
+ index.set(key, entries.sort((left, right) => left.range.startMs - right.range.startMs));
1141
+ });
1142
+ return index;
1143
+ }
1144
+ function buildEvaluationContext(params) {
1145
+ const evaluatedAt = toDateTime(params.evaluatedAt || params.projection.meta.now, params.projection.meta.timezone);
1146
+ const selectionsByGroupKey = new Map();
1147
+ (params.requirementSelections || []).forEach(selection => {
1148
+ const key = selection.requirementGroupId || String(selection.formId ?? '');
1149
+ selectionsByGroupKey.set(key, selection);
630
1150
  });
631
1151
  return {
632
- groups,
633
- conflicts
1152
+ evaluatedAt,
1153
+ evaluatedAtMs: evaluatedAt,
1154
+ resourceIdSet: params.resourceIds?.length ? new Set(params.resourceIds.map(String)) : null,
1155
+ selectionsByGroupKey
634
1156
  };
635
1157
  }
636
- function combineCandidateGroups(groups) {
637
- if (!groups.length || groups.some(group => group.length === 0)) return [];
638
- return groups.reduce((combinations, group) => combinations.flatMap(combination => group.map(candidate => [...combination, candidate])), [[]]);
1158
+ function summarizeStatuses(statuses) {
1159
+ if (!statuses.length) return 'unavailable';
1160
+ if (statuses.every(status => status === 'past')) return 'past';
1161
+ if (statuses.some(status => status === 'unavailable')) return 'unavailable';
1162
+ if (statuses.some(status => status === 'full')) return 'full';
1163
+ if (statuses.some(status => status === 'limited')) return 'limited';
1164
+ if (statuses.every(status => status === 'available')) return 'available';
1165
+ if (statuses.some(status => status === 'past')) return 'past';
1166
+ return statuses[0];
639
1167
  }
640
- function buildSlotFromCandidates(params) {
641
- const {
642
- interval,
643
- candidates,
644
- start,
645
- end
646
- } = params;
647
- if (!end.isAfter(start)) return null;
648
- const status = candidates.some(candidate => candidate.cell.status === 'limited') ? 'limited' : 'available';
649
- const assignments = candidates.map(candidate => ({
650
- productId: interval.productId,
651
- resourceId: candidate.resource.id,
652
- start_at: start.toISOString(),
653
- end_at: end.toISOString(),
654
- capacityRequired: Math.max(1, Number(candidate.requirement.capacityRequired || interval.capacityRequired || 1))
655
- }));
1168
+ function createStatusAggregate() {
1169
+ return {
1170
+ count: 0,
1171
+ hasAvailable: false,
1172
+ hasLimited: false,
1173
+ hasFull: false,
1174
+ allPast: true
1175
+ };
1176
+ }
1177
+ function pushStatusAggregate(aggregate, status) {
1178
+ aggregate.count += 1;
1179
+ if (status !== 'past') aggregate.allPast = false;
1180
+ if (status === 'available') aggregate.hasAvailable = true;
1181
+ if (status === 'limited') aggregate.hasLimited = true;
1182
+ if (status === 'full') aggregate.hasFull = true;
1183
+ }
1184
+ function summarizeStatusAggregate(aggregate) {
1185
+ if (aggregate.count === 0) return 'unavailable';
1186
+ if (aggregate.hasAvailable) return 'available';
1187
+ if (aggregate.hasLimited) return 'limited';
1188
+ if (aggregate.hasFull) return 'full';
1189
+ if (aggregate.allPast) return 'past';
1190
+ return 'unavailable';
1191
+ }
1192
+ function evaluateMatchedResourceSummary(params) {
1193
+ const isPast = params.candidateRange.endMs <= params.evaluationContext.evaluatedAtMs;
1194
+ if (!params.matchedCell) {
1195
+ return {
1196
+ status: isPast ? 'past' : 'unavailable',
1197
+ maxPartySize: 0,
1198
+ remainingCapacity: 0,
1199
+ maxCapacity: 0,
1200
+ reasonCodes: isPast ? ['past'] : ['outside_resource_window']
1201
+ };
1202
+ }
1203
+ const remainingCapacity = Math.max(0, params.matchedCell.remainingCapacity || 0);
1204
+ const maxCapacity = Math.max(0, params.matchedCell.maxCapacity ?? params.resourceCapacity ?? 0);
1205
+ const maxPartySize = isPast ? 0 : remainingCapacity;
1206
+ const status = (() => {
1207
+ if (isPast) return 'past';
1208
+ if (params.matchedCell.status === 'unavailable') return 'unavailable';
1209
+ if (remainingCapacity < params.requiredCapacity) return 'full';
1210
+ if (remainingCapacity < maxCapacity) return 'limited';
1211
+ return 'available';
1212
+ })();
656
1213
  return {
657
- key: [interval.productId, start.toISOString(), end.toISOString(), ...candidates.map(candidate => candidate.resource.id)].join(':'),
658
- productId: interval.productId,
659
- date: interval.date,
660
- start_at: start.toISOString(),
661
- end_at: end.toISOString(),
662
- startTime: start.format('HH:mm'),
663
- endTime: end.format('HH:mm'),
664
- durationMinutes: Math.max(1, end.diff(start, 'minute')),
665
1214
  status,
666
- resources: candidates.map(candidate => ({
667
- resourceId: candidate.resource.id,
668
- resourceName: candidate.resource.name,
669
- resourceFormId: candidate.resource.formId,
670
- resourceType: candidate.resource.resourceType,
671
- capacityRequired: Math.max(1, Number(candidate.requirement.capacityRequired || interval.capacityRequired || 1)),
672
- remainingCapacity: candidate.cell.remainingCapacity,
673
- maxCapacity: candidate.cell.maxCapacity
674
- })),
675
- assignments,
676
- conflicts: []
1215
+ remainingCapacity,
1216
+ maxCapacity,
1217
+ maxPartySize,
1218
+ reasonCodes: isPast ? Array.from(new Set([...(params.matchedCell.reasonCodes || []), 'past'])) : params.matchedCell.reasonCodes
677
1219
  };
678
1220
  }
679
- function buildSlotsForInterval(params) {
1221
+ function evaluateRequirementGroupSummary(params) {
680
1222
  const {
681
- interval,
682
- candidates,
683
- query,
684
- context
1223
+ projection,
1224
+ product,
1225
+ requirementGroup,
1226
+ candidateRange,
1227
+ requestedPartySize,
1228
+ evaluationContext
685
1229
  } = params;
686
- const start = candidates.reduce((current, candidate) => maxDayjs(current, (0, _dayjs.default)(candidate.cell.start_at)), (0, _dayjs.default)(interval.start_at));
687
- const end = candidates.reduce((current, candidate) => minDayjs(current, (0, _dayjs.default)(candidate.cell.end_at)), (0, _dayjs.default)(interval.end_at));
688
- const durationMinutes = Math.max(1, Number(interval.durationMinutes || 1));
689
- if (interval.source !== 'duration' || query.startTime) {
690
- const fixedStart = (0, _dayjs.default)(interval.start_at);
691
- const fixedEnd = (0, _dayjs.default)(interval.end_at);
692
- if (containsRange(start, end, fixedStart, fixedEnd) || candidates.every(candidate => candidate.cell.cellType === 'fixed_interval')) {
693
- const slot = buildSlotFromCandidates({
694
- interval,
695
- candidates,
696
- start: fixedStart,
697
- end: fixedEnd
698
- });
699
- return slot ? [slot] : [];
700
- }
701
- return [];
702
- }
703
- const stepMinutes = Math.max(1, Number(query.slotDurationMinutes || context.slotDurationMinutes || 30));
704
- const slots = [];
705
- let cursor = start;
706
- while (!cursor.add(durationMinutes, 'minute').isAfter(end)) {
707
- const slotEnd = cursor.add(durationMinutes, 'minute');
708
- const slot = buildSlotFromCandidates({
709
- interval,
710
- candidates,
711
- start: cursor,
712
- end: slotEnd
1230
+ const required = requirementGroup.required !== false;
1231
+ const minimum = Math.max(required ? 1 : 0, Number(requirementGroup.min ?? (required ? 1 : 0)) || 0);
1232
+ const maximum = Math.max(minimum, Number(requirementGroup.max ?? Math.max(minimum, 1)) || Math.max(minimum, 1));
1233
+ const matchedResources = params.groupResourceIndex?.get([String(product.id), requirementGroup.id].join(':')) || projection.resources.filter(resource => matchesRequirementGroup(requirementGroup, resource, product, requestedPartySize ?? 1)).filter(resource => !evaluationContext.resourceIdSet || evaluationContext.resourceIdSet.has(String(resource.id)));
1234
+ const requiredCapacity = resolveRequiredCapacity(requirementGroup, product, requestedPartySize);
1235
+ const optionStatusAggregate = createStatusAggregate();
1236
+ const reasonCodeSet = new Set();
1237
+ let totalResourceCount = 0;
1238
+ let availableResourceCount = 0;
1239
+ let groupMaxPartySize = 0;
1240
+ let groupRemainingCapacity = 0;
1241
+ let groupMaxCapacity = 0;
1242
+ matchedResources.forEach(resource => {
1243
+ totalResourceCount += 1;
1244
+ const matchedCell = aggregateMatchedCellSummary(candidateRange, params.cellIndex?.get([String(product.id), requirementGroup.id, String(resource.id)].join(':')) || []);
1245
+ const option = evaluateMatchedResourceSummary({
1246
+ matchedCell,
1247
+ resourceCapacity: resource.capacity,
1248
+ requiredCapacity,
1249
+ candidateRange,
1250
+ evaluationContext
713
1251
  });
714
- if (slot) slots.push(slot);
715
- cursor = cursor.add(stepMinutes, 'minute');
1252
+ pushStatusAggregate(optionStatusAggregate, option.status);
1253
+ option.reasonCodes.forEach(reasonCode => reasonCodeSet.add(reasonCode));
1254
+ groupRemainingCapacity = Math.max(groupRemainingCapacity, option.remainingCapacity);
1255
+ groupMaxCapacity = Math.max(groupMaxCapacity, option.maxCapacity);
1256
+ if (option.status === 'available' || option.status === 'limited') {
1257
+ availableResourceCount += 1;
1258
+ groupMaxPartySize = Math.max(groupMaxPartySize, option.maxPartySize);
1259
+ }
1260
+ });
1261
+ let status = 'available';
1262
+ if (candidateRange.endMs <= evaluationContext.evaluatedAtMs) {
1263
+ status = 'past';
1264
+ } else if (availableResourceCount < minimum) {
1265
+ status = optionStatusAggregate.hasAvailable || optionStatusAggregate.hasLimited || optionStatusAggregate.hasFull ? 'full' : 'unavailable';
1266
+ } else if (availableResourceCount === totalResourceCount && !optionStatusAggregate.hasLimited && !optionStatusAggregate.hasFull && !optionStatusAggregate.allPast) {
1267
+ status = 'available';
1268
+ } else {
1269
+ status = 'limited';
716
1270
  }
717
- return slots;
1271
+ return {
1272
+ required,
1273
+ min: minimum,
1274
+ max: maximum,
1275
+ status,
1276
+ maxPartySize: groupMaxPartySize,
1277
+ remainingCapacity: groupRemainingCapacity,
1278
+ maxCapacity: groupMaxCapacity,
1279
+ reasonCodes: Array.from(reasonCodeSet)
1280
+ };
718
1281
  }
719
- function resolveAssignableSlots(contextInput, query = {}) {
720
- const context = normalizePlannerContext(contextInput);
721
- const projection = queryPlannerAvailability(context, query);
722
- const resources = getQueryResources(context.resources, query);
723
- const slotsByKey = new Map();
724
- const conflicts = [];
725
- projection.demandIntervals.forEach(interval => {
726
- const {
727
- groups,
728
- conflicts: groupConflicts
729
- } = buildAssignableCandidateGroups({
730
- interval,
731
- cells: projection.cells,
732
- resources
1282
+ function evaluateCandidateSummary(projection, candidate, request) {
1283
+ const product = request.productIndex?.get(String(candidate.productId)) || projection.products.find(item => sameId(item.id, candidate.productId));
1284
+ if (!product) {
1285
+ throw availabilityError('INVALID_TARGET_FILTER', 'candidate.productId 不存在于 projection.products', {
1286
+ candidate
733
1287
  });
734
- conflicts.push(...groupConflicts);
735
- combineCandidateGroups(groups).forEach(candidates => {
736
- buildSlotsForInterval({
737
- interval,
738
- candidates,
739
- query,
740
- context
741
- }).forEach(slot => {
742
- if (!slotsByKey.has(slot.key)) slotsByKey.set(slot.key, slot);
743
- });
1288
+ }
1289
+ const candidateRangeCacheKey = getCandidateRangeCacheKey(candidate);
1290
+ const candidateRange = request.candidateRangeCache?.get(candidateRangeCacheKey) || (() => {
1291
+ const normalized = normalizeAbsoluteRange(getCandidateBookingRange(candidate), projection.meta.timezone);
1292
+ return normalized ? toIndexedRange(normalized) : null;
1293
+ })();
1294
+ if (!candidateRange) {
1295
+ throw availabilityError('INVALID_TARGET_FILTER', 'candidate 时间范围非法', {
1296
+ candidate
744
1297
  });
1298
+ }
1299
+ if (request.candidateRangeCache && !request.candidateRangeCache.has(candidateRangeCacheKey)) {
1300
+ request.candidateRangeCache.set(candidateRangeCacheKey, candidateRange);
1301
+ }
1302
+ const evaluationContext = request.evaluationContext || buildEvaluationContext({
1303
+ projection,
1304
+ evaluatedAt: request.evaluatedAt,
1305
+ resourceIds: request.resourceIds
745
1306
  });
746
- return {
1307
+ const requestedPartySize = normalizePartySize(request.partySize);
1308
+ const requiredGroups = (product.requirementGroups || []).filter(group => group.required !== false).map(requirementGroup => evaluateRequirementGroupSummary({
747
1309
  projection,
748
- slots: Array.from(slotsByKey.values()),
749
- conflicts
1310
+ product,
1311
+ requirementGroup,
1312
+ candidateRange,
1313
+ requestedPartySize,
1314
+ evaluationContext,
1315
+ cellIndex: request.cellIndex,
1316
+ groupResourceIndex: request.groupResourceIndex
1317
+ }));
1318
+ const gate = evaluateCandidateTimeGate({
1319
+ product,
1320
+ candidateRange,
1321
+ evaluationContext,
1322
+ timezoneName: projection.meta.timezone
1323
+ });
1324
+ const candidateStatus = requiredGroups.length ? summarizeStatuses(requiredGroups.map(group => group.status)) : candidateRange.endMs <= evaluationContext.evaluatedAtMs ? 'past' : 'available';
1325
+ const maxPartySize = requiredGroups.length ? requiredGroups.reduce((min, group) => Math.min(min, group.maxPartySize), Number.MAX_SAFE_INTEGER) : 0;
1326
+ const remainingCapacity = requiredGroups.length ? requiredGroups.reduce((min, group) => Math.min(min, group.remainingCapacity), Number.MAX_SAFE_INTEGER) : 0;
1327
+ const maxCapacity = requiredGroups.length ? requiredGroups.reduce((min, group) => Math.min(min, group.maxCapacity), Number.MAX_SAFE_INTEGER) : 0;
1328
+ const status = gate?.status || candidateStatus;
1329
+ return {
1330
+ status,
1331
+ maxPartySize: gate ? 0 : Number.isFinite(maxPartySize) ? maxPartySize : 0,
1332
+ remainingCapacity: status === 'past' || status === 'unavailable' ? 0 : Number.isFinite(remainingCapacity) ? remainingCapacity : 0,
1333
+ maxCapacity: Number.isFinite(maxCapacity) ? maxCapacity : 0,
1334
+ reasonCodes: gate?.reasonCodes || Array.from(new Set(requiredGroups.flatMap(group => group.reasonCodes)))
750
1335
  };
751
1336
  }
752
- function autoAssignResources(contextInput, query = {}) {
753
- const baseContext = normalizePlannerContext(contextInput);
754
- const projection = queryPlannerAvailability(baseContext, query);
755
- const assignments = [];
756
- const conflicts = [];
757
- const missingTimeProductIds = new Set();
758
- for (const interval of projection.demandIntervals) {
759
- if (interval.source === 'duration' && !query.startTime) {
760
- const productKey = String(interval.productId);
761
- if (!missingTimeProductIds.has(productKey)) {
762
- missingTimeProductIds.add(productKey);
763
- conflicts.push({
764
- type: 'missing_time',
765
- message: 'duration 商品需要先确定开始时间后才能自动分配资源',
766
- productId: interval.productId
767
- });
1337
+ function evaluateCandidate(projection, candidate, request) {
1338
+ const product = request.productIndex?.get(String(candidate.productId)) || projection.products.find(item => sameId(item.id, candidate.productId));
1339
+ if (!product) {
1340
+ throw availabilityError('INVALID_TARGET_FILTER', 'candidate.productId 不存在于 projection.products', {
1341
+ candidate
1342
+ });
1343
+ }
1344
+ const candidateRangeCacheKey = getCandidateRangeCacheKey(candidate);
1345
+ const candidateRange = request.candidateRangeCache?.get(candidateRangeCacheKey) || (() => {
1346
+ const normalized = normalizeAbsoluteRange(getCandidateBookingRange(candidate), projection.meta.timezone);
1347
+ return normalized ? toIndexedRange(normalized) : null;
1348
+ })();
1349
+ if (!candidateRange) {
1350
+ throw availabilityError('INVALID_TARGET_FILTER', 'candidate 时间范围非法', {
1351
+ candidate
1352
+ });
1353
+ }
1354
+ if (request.candidateRangeCache && !request.candidateRangeCache.has(candidateRangeCacheKey)) {
1355
+ request.candidateRangeCache.set(candidateRangeCacheKey, candidateRange);
1356
+ }
1357
+ const evaluationContext = request.evaluationContext || buildEvaluationContext({
1358
+ projection,
1359
+ evaluatedAt: request.evaluatedAt,
1360
+ resourceIds: request.resourceIds,
1361
+ requirementSelections: request.requirementSelections
1362
+ });
1363
+ const requestedPartySize = normalizePartySize(request.partySize);
1364
+ const partySize = requestedPartySize ?? 1;
1365
+ const selectionsByGroupKey = evaluationContext.selectionsByGroupKey;
1366
+ const requirementGroups = (product.requirementGroups || []).map(requirementGroup => {
1367
+ const required = requirementGroup.required !== false;
1368
+ const minimum = Math.max(required ? 1 : 0, Number(requirementGroup.min ?? (required ? 1 : 0)) || 0);
1369
+ const maximum = Math.max(minimum, Number(requirementGroup.max ?? Math.max(minimum, 1)) || Math.max(minimum, 1));
1370
+ const selection = selectionsByGroupKey.get(requirementGroup.id) || selectionsByGroupKey.get(String(requirementGroup.formId ?? ''));
1371
+ const matchedResources = (request.groupResourceIndex?.get([String(product.id), requirementGroup.id].join(':')) || projection.resources.filter(resource => matchesRequirementGroup(requirementGroup, resource, product, partySize)).filter(resource => !evaluationContext.resourceIdSet || evaluationContext.resourceIdSet.has(String(resource.id)))).filter(resource => !selection || selection.resourceIds.some(resourceId => sameId(resourceId, resource.id)));
1372
+ const options = matchedResources.map(resource => {
1373
+ const matchedCell = aggregateMatchedCells(product.id, requirementGroup.id, resource.id, candidateRange, request.cellIndex?.get([String(product.id), requirementGroup.id, String(resource.id)].join(':')) || []);
1374
+ if (!matchedCell) {
1375
+ const isPast = candidateRange.endMs <= evaluationContext.evaluatedAtMs;
1376
+ return {
1377
+ resourceId: resource.id,
1378
+ resourceName: resource.name,
1379
+ resourceFormId: resource.formId,
1380
+ policyId: resource.raw?.policy_id,
1381
+ resourceType: resource.resourceType,
1382
+ status: isPast ? 'past' : 'unavailable',
1383
+ maxPartySize: 0,
1384
+ reasonCodes: isPast ? ['past'] : ['outside_resource_window']
1385
+ };
768
1386
  }
769
- continue;
770
- }
771
- const contextWithAssignments = withAssignmentEvents(baseContext, assignments);
772
- const candidateResources = getQueryResources(contextWithAssignments.resources, query).filter(resource => {
773
- const requirements = interval.resourceRequirements || [];
774
- if (!requirements.length) return true;
775
- return requirements.some(requirement => matchesResourceRequirement(resource, requirement));
1387
+ const remainingCapacity = matchedCell.remainingCapacity;
1388
+ const maxCapacity = matchedCell.maxCapacity;
1389
+ const isPast = candidateRange.endMs <= evaluationContext.evaluatedAtMs;
1390
+ const maxPartySize = isPast ? 0 : Math.max(0, remainingCapacity || 0);
1391
+ const requiredCapacity = resolveRequiredCapacity(requirementGroup, product, requestedPartySize);
1392
+ const status = (() => {
1393
+ if (isPast) return 'past';
1394
+ if (matchedCell.status === 'unavailable') return 'unavailable';
1395
+ if ((remainingCapacity || 0) < requiredCapacity) return 'full';
1396
+ if ((remainingCapacity || 0) < (maxCapacity || resource.capacity)) return 'limited';
1397
+ return 'available';
1398
+ })();
1399
+ return {
1400
+ resourceId: resource.id,
1401
+ resourceName: resource.name,
1402
+ resourceFormId: resource.formId,
1403
+ policyId: resource.raw?.policy_id,
1404
+ resourceType: resource.resourceType,
1405
+ status,
1406
+ remainingCapacity,
1407
+ maxCapacity,
1408
+ maxPartySize,
1409
+ reasonCodes: isPast ? Array.from(new Set([...matchedCell.reasonCodes, 'past'])) : matchedCell.reasonCodes,
1410
+ matchedCell
1411
+ };
776
1412
  });
777
- const availableCell = candidateResources.map(resource => evaluateResourceForInterval({
778
- resource,
779
- interval,
780
- context: contextWithAssignments
781
- })).find(cell => cell.status === 'available' || cell.status === 'limited');
782
- if (!availableCell?.resourceId) {
783
- conflicts.push({
784
- type: 'missing_resource',
785
- message: '无法为商品自动分配可用资源',
786
- productId: interval.productId
787
- });
788
- continue;
1413
+ const availableOptions = options.filter(option => option.status === 'available' || option.status === 'limited');
1414
+ const totalResourceCount = options.length;
1415
+ const availableResourceCount = availableOptions.length;
1416
+ const groupMaxPartySize = availableOptions.reduce((max, option) => Math.max(max, option.maxPartySize), 0);
1417
+ let status = 'available';
1418
+ if (candidateRange.endMs <= evaluationContext.evaluatedAtMs) {
1419
+ status = 'past';
1420
+ } else if (availableResourceCount < minimum) {
1421
+ status = options.some(option => option.status === 'full' || option.status === 'limited' || option.status === 'available') ? 'full' : 'unavailable';
1422
+ } else if (options.every(option => option.status === 'available')) {
1423
+ status = 'available';
1424
+ } else {
1425
+ status = 'limited';
789
1426
  }
790
- assignments.push({
791
- productId: interval.productId,
792
- resourceId: availableCell.resourceId,
793
- start_at: interval.start_at,
794
- end_at: interval.end_at,
795
- capacityRequired: interval.capacityRequired
1427
+ return {
1428
+ groupId: requirementGroup.id,
1429
+ formId: requirementGroup.formId,
1430
+ title: requirementGroup.title,
1431
+ code: requirementGroup.code,
1432
+ resourceType: requirementGroup.resourceType,
1433
+ required,
1434
+ min: minimum,
1435
+ max: maximum,
1436
+ status,
1437
+ availableResourceCount,
1438
+ totalResourceCount,
1439
+ maxPartySize: groupMaxPartySize,
1440
+ remainingCapacity: Math.max(...availableOptions.map(option => option.remainingCapacity || 0), 0),
1441
+ maxCapacity: Math.max(...availableOptions.map(option => option.maxCapacity || 0), 0),
1442
+ reasonCodes: Array.from(new Set(options.flatMap(option => option.reasonCodes))),
1443
+ options
1444
+ };
1445
+ });
1446
+ const requiredGroups = requirementGroups.filter(group => group.required);
1447
+ const requiredStatuses = requiredGroups.map(group => group.status);
1448
+ const gate = evaluateCandidateTimeGate({
1449
+ product,
1450
+ candidateRange,
1451
+ evaluationContext,
1452
+ timezoneName: projection.meta.timezone
1453
+ });
1454
+ const candidateStatus = requiredGroups.length ? summarizeStatuses(requiredStatuses) : candidateRange.endMs <= evaluationContext.evaluatedAtMs ? 'past' : 'available';
1455
+ const maxPartySize = requiredGroups.length ? requiredGroups.reduce((min, group) => Math.min(min, group.maxPartySize), Number.MAX_SAFE_INTEGER) : 0;
1456
+ const selectedAssignments = requirementGroups.flatMap(group => {
1457
+ const selection = selectionsByGroupKey.get(group.groupId) || selectionsByGroupKey.get(String(group.formId ?? ''));
1458
+ if (!selection && !(request.allowImplicitSelection && group.required)) return [];
1459
+ const availableOptions = group.options.filter(option => option.status === 'available' || option.status === 'limited');
1460
+ const selectedOptions = selection ? availableOptions.slice(0, group.max) : availableOptions.slice(0, Math.max(group.min, 1));
1461
+ const originalGroup = (product.requirementGroups || []).find(item => item.id === group.groupId) || {
1462
+ id: group.groupId,
1463
+ resourceType: group.resourceType,
1464
+ min: group.min,
1465
+ max: group.max
1466
+ };
1467
+ return selectedOptions.map(selectedOption => ({
1468
+ productId: product.id,
1469
+ resourceId: selectedOption.resourceId,
1470
+ formId: group.formId,
1471
+ startAt: candidate.bookingStartAt || candidate.startAt,
1472
+ endAt: candidate.bookingEndAt || candidate.endAt,
1473
+ capacityRequired: resolveRequiredCapacity(originalGroup, product, requestedPartySize),
1474
+ scheduleId: candidate.primaryScheduleRef?.scheduleId,
1475
+ timeSlotId: candidate.primaryScheduleRef?.timeSlotId
1476
+ }));
1477
+ });
1478
+ return {
1479
+ item: {
1480
+ ...candidate,
1481
+ status: gate?.status || candidateStatus,
1482
+ maxPartySize: gate ? 0 : Number.isFinite(maxPartySize) ? maxPartySize : 0,
1483
+ reasonCodes: gate?.reasonCodes || Array.from(new Set(requiredGroups.flatMap(group => group.reasonCodes))),
1484
+ requirementGroups
1485
+ },
1486
+ assignments: gate ? [] : selectedAssignments,
1487
+ gate: gate || undefined
1488
+ };
1489
+ }
1490
+ function assertTimeCandidates(request) {
1491
+ if (request.target === 'time' && (!Array.isArray(request.candidates) || request.candidates.length === 0)) {
1492
+ throw availabilityError('MISSING_TIME_CANDIDATES', 'target=time 必须传非空 candidates', {
1493
+ request
796
1494
  });
797
1495
  }
798
- return {
799
- assignments,
800
- conflicts
1496
+ }
1497
+ function assertSelectedRanges(request) {
1498
+ if (request.target === 'resource' && (!Array.isArray(request.selectedRanges) || request.selectedRanges.length === 0)) {
1499
+ throw availabilityError('MISSING_SELECTED_RANGE', 'target=resource 必须传非空 selectedRanges', {
1500
+ request
1501
+ });
1502
+ }
1503
+ }
1504
+ function assertRangesWithinProjection(projection, ranges) {
1505
+ const coverage = {
1506
+ start: toDateTime(projection.meta.coverage.startAt, projection.meta.timezone),
1507
+ end: toDateTime(projection.meta.coverage.endAt, projection.meta.timezone)
801
1508
  };
1509
+ ranges.forEach(range => {
1510
+ const displayRange = normalizeAbsoluteRange(range, projection.meta.timezone);
1511
+ const bookingRange = normalizeAbsoluteRange(getCandidateBookingRange(range), projection.meta.timezone);
1512
+ if (!displayRange || !bookingRange) {
1513
+ throw availabilityError('INVALID_TARGET_FILTER', '候选时间范围非法', {
1514
+ range
1515
+ });
1516
+ }
1517
+ if (!contains(coverage, displayRange) || !contains(coverage, bookingRange)) {
1518
+ throw availabilityError('OUT_OF_PROJECTION_COVERAGE', '候选时间范围超出 projection.coverage', {
1519
+ range,
1520
+ coverage: projection.meta.coverage
1521
+ });
1522
+ }
1523
+ });
802
1524
  }
803
- function withAssignmentEvents(context, assignments) {
804
- if (!assignments.length) return context;
805
- return {
806
- ...context,
807
- externalEvents: [...context.externalEvents, ...assignments.map(assignment => ({
808
- resourceId: assignment.resourceId,
809
- start_at: assignment.start_at,
810
- end_at: assignment.end_at,
811
- pax: assignment.capacityRequired || 1,
812
- source: 'selection'
813
- }))]
814
- };
815
- }
816
- function validateAssignments(contextInput, selection) {
817
- if (selection.assignments.length > 0) {
818
- const context = normalizePlannerContext(contextInput);
819
- const conflicts = [];
820
- for (let index = 0; index < selection.assignments.length; index += 1) {
821
- const assignment = selection.assignments[index];
822
- const product = context.products.find(item => sameId(item.id, assignment.productId));
823
- const resource = context.resources.find(item => sameId(item.id, assignment.resourceId));
824
- if (!product) {
825
- conflicts.push({
826
- type: 'missing_resource',
827
- message: 'assignment 对应的商品不存在',
828
- productId: assignment.productId
829
- });
830
- continue;
831
- }
832
- if (!resource) {
833
- conflicts.push({
834
- type: 'missing_resource',
835
- message: 'assignment 对应的资源不存在',
836
- productId: assignment.productId,
837
- resourceId: assignment.resourceId
838
- });
839
- continue;
840
- }
841
- const start = (0, _dayjs.default)(assignment.start_at);
842
- const end = (0, _dayjs.default)(assignment.end_at);
843
- const interval = {
844
- id: `${assignment.productId}:${assignment.resourceId}:${assignment.start_at}:${assignment.end_at}`,
845
- productId: assignment.productId,
846
- source: product.kind === 'session' ? 'session' : product.kind === 'venue_slot' ? 'venue_slot' : 'duration',
847
- date: start.format('YYYY-MM-DD'),
848
- start_at: assignment.start_at,
849
- end_at: assignment.end_at,
850
- durationMinutes: Math.max(1, end.diff(start, 'minute')),
851
- quantity: Math.max(1, Number(product.quantity || 1)),
852
- capacityRequired: Math.max(1, Number(assignment.capacityRequired || product.capacityRequired || 1)),
853
- resourceRequirements: product.resourceRequirements || []
1525
+ function queryAvailabilityProjection(projection, request) {
1526
+ const normalizedPartySize = normalizePartySize(request.partySize);
1527
+ const evaluatedAt = formatDateTime((0, _localClock.localDateToScalar)(new Date()), projection.meta.timezone, (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)());
1528
+ if (request.target === 'time') {
1529
+ assertTimeCandidates(request);
1530
+ assertRangesWithinProjection(projection, request.candidates);
1531
+ }
1532
+ if (request.target === 'resource') {
1533
+ assertSelectedRanges(request);
1534
+ assertRangesWithinProjection(projection, request.selectedRanges);
1535
+ }
1536
+ const productIdSet = request.productIds?.length ? new Set(request.productIds.map(String)) : null;
1537
+ const productIndex = buildProductIndex(projection);
1538
+ const cellIndex = buildAvailabilityCellIndex(projection);
1539
+ const groupResourceIndex = buildGroupResourceIndex({
1540
+ projection,
1541
+ resourceIds: request.resourceIds,
1542
+ partySize: normalizedPartySize
1543
+ });
1544
+ const candidateRangeCache = new Map();
1545
+ const evaluationContext = buildEvaluationContext({
1546
+ projection,
1547
+ evaluatedAt,
1548
+ resourceIds: request.resourceIds
1549
+ });
1550
+ if (request.target === 'time') {
1551
+ const candidates = dedupeProductRanges(request.candidates);
1552
+ const candidatesByProduct = new Map();
1553
+ candidates.forEach(candidate => {
1554
+ const key = String(candidate.productId);
1555
+ candidatesByProduct.set(key, [...(candidatesByProduct.get(key) || []), candidate]);
1556
+ });
1557
+ const data = projection.products.filter(product => !productIdSet || productIdSet.has(String(product.id))).map(product => ({
1558
+ productId: product.id,
1559
+ title: product.title,
1560
+ times: (candidatesByProduct.get(String(product.id)) || []).map(candidate => evaluateCandidate(projection, candidate, {
1561
+ partySize: normalizedPartySize,
1562
+ resourceIds: request.resourceIds,
1563
+ evaluatedAt,
1564
+ cellIndex,
1565
+ productIndex,
1566
+ groupResourceIndex,
1567
+ candidateRangeCache,
1568
+ evaluationContext
1569
+ }).item)
1570
+ })).filter(group => group.times.length > 0);
1571
+ return {
1572
+ target: 'time',
1573
+ evaluatedAt,
1574
+ snapshotId: projection.meta.snapshotId,
1575
+ data
1576
+ };
1577
+ }
1578
+ if (request.target === 'resource') {
1579
+ const selectedRanges = dedupeProductRanges(request.selectedRanges);
1580
+ const rangesByProduct = new Map();
1581
+ selectedRanges.forEach(candidate => {
1582
+ const key = String(candidate.productId);
1583
+ rangesByProduct.set(key, [...(rangesByProduct.get(key) || []), candidate]);
1584
+ });
1585
+ const data = projection.products.filter(product => !productIdSet || productIdSet.has(String(product.id))).map(product => ({
1586
+ productId: product.id,
1587
+ title: product.title,
1588
+ ranges: (rangesByProduct.get(String(product.id)) || []).map(candidate => {
1589
+ const evaluated = evaluateCandidate(projection, candidate, {
1590
+ partySize: normalizedPartySize,
1591
+ resourceIds: request.resourceIds,
1592
+ evaluatedAt,
1593
+ cellIndex,
1594
+ productIndex,
1595
+ groupResourceIndex,
1596
+ candidateRangeCache,
1597
+ evaluationContext
1598
+ }).item;
1599
+ return {
1600
+ range: candidate,
1601
+ status: evaluated.status,
1602
+ maxPartySize: evaluated.maxPartySize,
1603
+ reasonCodes: evaluated.reasonCodes,
1604
+ requirementGroups: evaluated.requirementGroups
1605
+ };
1606
+ })
1607
+ })).filter(group => group.ranges.length > 0);
1608
+ return {
1609
+ target: 'resource',
1610
+ evaluatedAt,
1611
+ snapshotId: projection.meta.snapshotId,
1612
+ data
1613
+ };
1614
+ }
1615
+ const dateRange = resolveProjectionDateRange(projection, request.dateRange);
1616
+ const candidateGroups = getProductTimeRanges(projection, {
1617
+ productIds: request.productIds,
1618
+ dateRange,
1619
+ resourceIds: request.resourceIds,
1620
+ partySize: normalizedPartySize
1621
+ }, {
1622
+ evaluatedAt
1623
+ });
1624
+ const candidateGroupMap = new Map(candidateGroups.map(group => [String(group.productId), group]));
1625
+ if (request.target === 'date') {
1626
+ const data = projection.products.filter(product => !productIdSet || productIdSet.has(String(product.id))).map(product => {
1627
+ const group = candidateGroupMap.get(String(product.id)) || {
1628
+ productId: product.id,
1629
+ title: product.title,
1630
+ kind: product.kind,
1631
+ ranges: []
854
1632
  };
855
- const cell = evaluateResourceForInterval({
856
- resource,
857
- interval,
858
- context
1633
+ const byDate = new Map();
1634
+ group.ranges.forEach(candidate => {
1635
+ const evaluated = evaluateCandidateSummary(projection, candidate, {
1636
+ partySize: normalizedPartySize,
1637
+ resourceIds: request.resourceIds,
1638
+ evaluatedAt,
1639
+ cellIndex,
1640
+ productIndex,
1641
+ groupResourceIndex,
1642
+ candidateRangeCache,
1643
+ evaluationContext
1644
+ });
1645
+ const aggregate = byDate.get(candidate.date) || {
1646
+ availableCount: 0,
1647
+ totalCount: 0,
1648
+ maxPartySize: 0,
1649
+ remainingCapacity: 0,
1650
+ maxCapacity: 0,
1651
+ reasonCodeSet: new Set(),
1652
+ statusAggregate: createStatusAggregate()
1653
+ };
1654
+ aggregate.totalCount += 1;
1655
+ if (evaluated.status === 'available' || evaluated.status === 'limited') {
1656
+ aggregate.availableCount += 1;
1657
+ }
1658
+ aggregate.maxPartySize = Math.max(aggregate.maxPartySize, evaluated.maxPartySize);
1659
+ aggregate.remainingCapacity += evaluated.remainingCapacity;
1660
+ aggregate.maxCapacity += evaluated.maxCapacity;
1661
+ pushStatusAggregate(aggregate.statusAggregate, evaluated.status);
1662
+ evaluated.reasonCodes.forEach(reasonCode => aggregate.reasonCodeSet.add(reasonCode));
1663
+ byDate.set(candidate.date, aggregate);
859
1664
  });
860
- if (cell.status !== 'available' && cell.status !== 'limited') {
861
- conflicts.push(...cell.conflicts);
862
- }
863
- const priorContext = withAssignmentEvents(context, selection.assignments.slice(0, index));
864
- const selfConflictCell = evaluateResourceForInterval({
865
- resource,
866
- interval,
867
- context: priorContext
1665
+ const dates = enumerateDates(dateRange.startDate, dateRange.endDate).map(date => {
1666
+ const aggregate = byDate.get(date);
1667
+ return {
1668
+ date,
1669
+ status: aggregate ? summarizeStatusAggregate(aggregate.statusAggregate) : 'unavailable',
1670
+ availableCount: aggregate?.availableCount || 0,
1671
+ totalCount: aggregate?.totalCount || 0,
1672
+ maxPartySize: aggregate?.maxPartySize || 0,
1673
+ remainingCapacity: aggregate?.remainingCapacity || 0,
1674
+ maxCapacity: aggregate?.maxCapacity || 0,
1675
+ reasonCodes: aggregate ? Array.from(aggregate.reasonCodeSet) : []
1676
+ };
1677
+ });
1678
+ return {
1679
+ productId: product.id,
1680
+ title: product.title,
1681
+ dates
1682
+ };
1683
+ });
1684
+ return {
1685
+ target: 'date',
1686
+ evaluatedAt,
1687
+ snapshotId: projection.meta.snapshotId,
1688
+ data
1689
+ };
1690
+ }
1691
+ const data = projection.products.filter(product => !productIdSet || productIdSet.has(String(product.id))).map(product => {
1692
+ const group = candidateGroupMap.get(String(product.id)) || {
1693
+ productId: product.id,
1694
+ title: product.title,
1695
+ kind: product.kind,
1696
+ ranges: []
1697
+ };
1698
+ const productStatusAggregate = createStatusAggregate();
1699
+ const productReasonCodeSet = new Set();
1700
+ const dateStatusMap = new Map();
1701
+ let availableTimeCount = 0;
1702
+ let totalTimeCount = 0;
1703
+ let maxPartySize = 0;
1704
+ group.ranges.forEach(candidate => {
1705
+ const evaluated = evaluateCandidateSummary(projection, candidate, {
1706
+ partySize: normalizedPartySize,
1707
+ resourceIds: request.resourceIds,
1708
+ evaluatedAt,
1709
+ cellIndex,
1710
+ productIndex,
1711
+ groupResourceIndex,
1712
+ candidateRangeCache,
1713
+ evaluationContext
868
1714
  });
869
- if (selfConflictCell.status !== 'available' && selfConflictCell.status !== 'limited') {
870
- conflicts.push(...selfConflictCell.conflicts.map(conflict => ({
871
- ...conflict,
872
- type: 'cart_overlap',
873
- message: '本次选择中存在资源时间冲突'
874
- })));
1715
+ totalTimeCount += 1;
1716
+ if (evaluated.status === 'available' || evaluated.status === 'limited') {
1717
+ availableTimeCount += 1;
875
1718
  }
876
- }
1719
+ maxPartySize = Math.max(maxPartySize, evaluated.maxPartySize);
1720
+ pushStatusAggregate(productStatusAggregate, evaluated.status);
1721
+ evaluated.reasonCodes.forEach(reasonCode => productReasonCodeSet.add(reasonCode));
1722
+ const dateAggregate = dateStatusMap.get(candidate.date) || createStatusAggregate();
1723
+ pushStatusAggregate(dateAggregate, evaluated.status);
1724
+ dateStatusMap.set(candidate.date, dateAggregate);
1725
+ });
1726
+ const availableDateCount = Array.from(dateStatusMap.values()).filter(aggregate => {
1727
+ const status = summarizeStatusAggregate(aggregate);
1728
+ return status === 'available' || status === 'limited';
1729
+ }).length;
1730
+ const totalDateCount = dateStatusMap.size;
1731
+ const status = summarizeStatusAggregate(productStatusAggregate);
1732
+ const item = {
1733
+ productId: group.productId,
1734
+ title: group.title,
1735
+ status,
1736
+ reasonCodes: Array.from(productReasonCodeSet),
1737
+ availableDateCount,
1738
+ totalDateCount,
1739
+ availableTimeCount,
1740
+ totalTimeCount,
1741
+ maxPartySize
1742
+ };
877
1743
  return {
878
- ok: conflicts.length === 0,
879
- conflicts
1744
+ productId: product.id,
1745
+ title: product.title,
1746
+ item
1747
+ };
1748
+ });
1749
+ return {
1750
+ target: 'product',
1751
+ evaluatedAt,
1752
+ snapshotId: projection.meta.snapshotId,
1753
+ data
1754
+ };
1755
+ }
1756
+ function summarizeDateStatus(statuses) {
1757
+ if (!statuses.length) return 'unavailable';
1758
+ if (statuses.some(status => status === 'available')) return 'available';
1759
+ if (statuses.some(status => status === 'limited')) return 'limited';
1760
+ if (statuses.some(status => status === 'full')) return 'full';
1761
+ if (statuses.every(status => status === 'past')) return 'past';
1762
+ return 'unavailable';
1763
+ }
1764
+ function summarizeProductStatus(statuses) {
1765
+ if (!statuses.length) return 'unavailable';
1766
+ if (statuses.some(status => status === 'available')) return 'available';
1767
+ if (statuses.some(status => status === 'limited')) return 'limited';
1768
+ if (statuses.some(status => status === 'full')) return 'full';
1769
+ if (statuses.every(status => status === 'past')) return 'past';
1770
+ return 'unavailable';
1771
+ }
1772
+ function validateAvailabilitySelection(projection, params) {
1773
+ const normalizedPartySize = normalizePartySize(params.partySize);
1774
+ const normalizedBookingCount = normalizeBookingCount(params.bookingCount);
1775
+ assertRangesWithinProjection(projection, [params.candidate]);
1776
+ const productIndex = buildProductIndex(projection);
1777
+ const evaluatedAt = formatDateTime((0, _localClock.localDateToScalar)(new Date()), projection.meta.timezone, (0, _localClock.extractFixedOffsetMinutes)(projection.meta.now) ?? (0, _localClock.captureRuntimeOffsetMinutes)());
1778
+ const evaluated = evaluateCandidate(projection, params.candidate, {
1779
+ partySize: normalizedPartySize,
1780
+ evaluatedAt,
1781
+ requirementSelections: params.requirementSelections,
1782
+ allowImplicitSelection: false,
1783
+ cellIndex: buildAvailabilityCellIndex(projection),
1784
+ productIndex,
1785
+ groupResourceIndex: buildGroupResourceIndex({
1786
+ projection,
1787
+ partySize: normalizedPartySize
1788
+ }),
1789
+ candidateRangeCache: new Map(),
1790
+ evaluationContext: buildEvaluationContext({
1791
+ projection,
1792
+ evaluatedAt,
1793
+ requirementSelections: params.requirementSelections
1794
+ })
1795
+ });
1796
+ const conflicts = [];
1797
+ if (evaluated.gate) {
1798
+ conflicts.push({
1799
+ code: evaluated.gate.conflictCode,
1800
+ message: evaluated.gate.status === 'past' ? '候选时间已过期' : '候选时间不满足预订时间门禁',
1801
+ reasonCodes: evaluated.gate.reasonCodes,
1802
+ productId: params.candidate.productId,
1803
+ candidateKey: params.candidate.key
1804
+ });
1805
+ return {
1806
+ ok: false,
1807
+ status: evaluated.gate.status === 'past' ? 'past' : 'conflict',
1808
+ assignments: [],
1809
+ conflicts,
1810
+ availability: evaluated.item
880
1811
  };
881
1812
  }
882
- const projection = queryPlannerAvailability(contextInput, {
883
- productIds: selection.productIds,
884
- date: selection.date,
885
- startTime: selection.startTime,
886
- endTime: selection.endTime,
887
- resourceIds: selection.resourceIds,
888
- mode: selection.mode
1813
+ const requiredGroups = evaluated.item.requirementGroups.filter(group => group.required);
1814
+ const findSelectionForGroup = group => params.requirementSelections.find(selection => selection.requirementGroupId && selection.requirementGroupId === group.groupId || selection.formId !== undefined && sameId(selection.formId, group.formId));
1815
+ requiredGroups.forEach(group => {
1816
+ const selection = findSelectionForGroup(group);
1817
+ const selectedCount = new Set((selection?.resourceIds || []).map(String)).size;
1818
+ if (selectedCount < group.min) {
1819
+ conflicts.push({
1820
+ code: 'missing_resource',
1821
+ message: `资源组 ${group.groupId} 缺少已选资源`,
1822
+ productId: params.candidate.productId,
1823
+ requirementGroupId: group.groupId,
1824
+ formId: group.formId,
1825
+ candidateKey: params.candidate.key
1826
+ });
1827
+ return;
1828
+ }
1829
+ if (selectedCount > group.max) {
1830
+ conflicts.push({
1831
+ code: 'invalid_selection_count',
1832
+ message: `资源组 ${group.groupId} 已选资源数量超出上限`,
1833
+ productId: params.candidate.productId,
1834
+ requirementGroupId: group.groupId,
1835
+ formId: group.formId,
1836
+ candidateKey: params.candidate.key
1837
+ });
1838
+ return;
1839
+ }
1840
+ if (group.status === 'available' || group.status === 'limited') return;
1841
+ conflicts.push({
1842
+ code: group.status === 'past' ? 'past' : group.status === 'full' ? 'resource_full' : 'missing_resource',
1843
+ message: `资源组 ${group.groupId} 当前不可提交`,
1844
+ productId: params.candidate.productId,
1845
+ requirementGroupId: group.groupId,
1846
+ formId: group.formId,
1847
+ candidateKey: params.candidate.key
1848
+ });
1849
+ });
1850
+ params.requirementSelections.forEach(selection => {
1851
+ const matchedGroup = evaluated.item.requirementGroups.find(group => selection.requirementGroupId ? group.groupId === selection.requirementGroupId : sameId(group.formId, selection.formId));
1852
+ if (!matchedGroup) {
1853
+ conflicts.push({
1854
+ code: 'invalid_requirement_group',
1855
+ message: '选择的资源组不属于当前商品',
1856
+ productId: params.candidate.productId,
1857
+ requirementGroupId: selection.requirementGroupId,
1858
+ formId: selection.formId,
1859
+ candidateKey: params.candidate.key
1860
+ });
1861
+ return;
1862
+ }
1863
+ const selectedCount = new Set(selection.resourceIds.map(String)).size;
1864
+ if (!matchedGroup.required && (selectedCount < matchedGroup.min || selectedCount > matchedGroup.max)) {
1865
+ conflicts.push({
1866
+ code: 'invalid_selection_count',
1867
+ message: `资源组 ${matchedGroup.groupId} 已选资源数量不满足 ${matchedGroup.min}-${matchedGroup.max}`,
1868
+ productId: params.candidate.productId,
1869
+ requirementGroupId: matchedGroup.groupId,
1870
+ formId: matchedGroup.formId,
1871
+ candidateKey: params.candidate.key
1872
+ });
1873
+ }
1874
+ selection.resourceIds.forEach(resourceId => {
1875
+ const option = matchedGroup?.options.find(item => sameId(item.resourceId, resourceId));
1876
+ const matchedCell = option?.matchedCell;
1877
+ const remainingCapacity = matchedCell?.remainingCapacity;
1878
+ if (option && (option.status === 'available' || option.status === 'limited') && remainingCapacity !== undefined) {
1879
+ const capacityPerBooking = normalizedPartySize ?? 1;
1880
+ const availableQuantity = Math.max(0, Math.floor(remainingCapacity / capacityPerBooking));
1881
+ if (normalizedBookingCount > availableQuantity) {
1882
+ conflicts.push({
1883
+ code: 'resource_capacity_exceeded',
1884
+ message: `所选资源「${option.resourceName || String(resourceId)}」当前最多可预订 ${availableQuantity} 个`,
1885
+ productId: params.candidate.productId,
1886
+ requirementGroupId: matchedGroup?.groupId,
1887
+ formId: matchedGroup?.formId,
1888
+ resourceId,
1889
+ resourceName: option.resourceName,
1890
+ candidateKey: params.candidate.key,
1891
+ requestedQuantity: normalizedBookingCount,
1892
+ availableQuantity,
1893
+ remainingCapacity,
1894
+ capacityPerBooking
1895
+ });
1896
+ return;
1897
+ }
1898
+ }
1899
+ if (!option || option.status !== 'available' && option.status !== 'limited') {
1900
+ conflicts.push({
1901
+ code: 'resource_unavailable',
1902
+ message: `资源 ${String(resourceId)} 当前不可提交`,
1903
+ productId: params.candidate.productId,
1904
+ requirementGroupId: matchedGroup?.groupId,
1905
+ formId: matchedGroup?.formId,
1906
+ resourceId,
1907
+ candidateKey: params.candidate.key
1908
+ });
1909
+ }
1910
+ });
889
1911
  });
890
- const conflicts = projection.cells.filter(cell => cell.status !== 'available' && cell.status !== 'limited').flatMap(cell => cell.conflicts);
891
1912
  return {
892
- ok: conflicts.length === 0,
893
- conflicts
1913
+ ok: conflicts.length === 0 && (evaluated.item.status === 'available' || evaluated.item.status === 'limited'),
1914
+ status: conflicts.length === 0 ? evaluated.item.status : 'conflict',
1915
+ assignments: evaluated.assignments,
1916
+ conflicts,
1917
+ availability: evaluated.item
894
1918
  };
895
1919
  }