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