@pisell/pisellos 2.3.71 → 2.3.73

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