@happychef/algorithm 1.2.11 → 1.2.12
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/.github/workflows/ci-cd.yml +234 -234
- package/BRANCH_PROTECTION_SETUP.md +167 -167
- package/CHANGELOG.md +8 -8
- package/README.md +144 -144
- package/RESERVERINGEN_GIDS.md +986 -986
- package/__tests__/filters.test.js +276 -276
- package/__tests__/isDateAvailable.test.js +175 -175
- package/__tests__/isTimeAvailable.test.js +168 -168
- package/__tests__/restaurantData.test.js +422 -422
- package/__tests__/tableHelpers.test.js +247 -247
- package/assignTables.js +424 -424
- package/changes/2025/December/PR2___change.md +14 -14
- package/changes/2025/December/PR3_add__change.md +20 -20
- package/changes/2025/December/PR4___.md +15 -15
- package/changes/2025/December/PR5___.md +15 -15
- package/changes/2025/December/PR6__del_.md +17 -17
- package/changes/2025/December/PR7_add__change.md +21 -21
- package/changes/2026/January/PR8_add__change.md +39 -0
- package/changes/2026/January/PR9_add__change.md +20 -0
- package/filters/maxArrivalsFilter.js +114 -114
- package/filters/maxGroupsFilter.js +221 -221
- package/filters/timeFilter.js +89 -89
- package/getAvailableTimeblocks.js +158 -158
- package/grouping.js +162 -162
- package/index.js +42 -42
- package/isDateAvailable.js +80 -80
- package/isDateAvailableWithTableCheck.js +171 -171
- package/isTimeAvailable.js +25 -25
- package/jest.config.js +23 -23
- package/package.json +27 -27
- package/processing/dailyGuestCounts.js +73 -73
- package/processing/mealTypeCount.js +133 -133
- package/processing/timeblocksAvailable.js +167 -167
- package/reservation_data/counter.js +64 -64
- package/restaurant_data/exceptions.js +149 -149
- package/restaurant_data/openinghours.js +123 -123
- package/simulateTableAssignment.js +709 -699
- package/tableHelpers.js +178 -178
- package/tables/time/parseTime.js +19 -19
- package/tables/time/shifts.js +7 -7
- package/tables/utils/calculateDistance.js +13 -13
- package/tables/utils/isTableFreeForAllSlots.js +14 -14
- package/tables/utils/isTemporaryTableValid.js +39 -39
- package/test/test_counter.js +194 -194
- package/test/test_dailyCount.js +81 -81
- package/test/test_datesAvailable.js +106 -106
- package/test/test_exceptions.js +172 -172
- package/test/test_isDateAvailable.js +330 -330
- package/test/test_mealTypeCount.js +54 -54
- package/test/test_timesAvailable.js +88 -88
- package/test-detailed-filter.js +100 -100
- package/test-lunch-debug.js +110 -110
- package/test-max-arrivals-filter.js +79 -79
- package/test-meal-stop-fix.js +147 -147
- package/test-meal-stop-simple.js +93 -93
- package/test-timezone-debug.js +47 -47
- package/test.js +336 -336
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
// getAvailableTimeblocks.js
|
|
2
|
-
|
|
3
|
-
const { timeblocksAvailable } = require('./processing/timeblocksAvailable');
|
|
4
|
-
const { parseTime, getMealTypeByTime } = require('./tableHelpers');
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Parses a time string in "HH:MM" format into a Date object on a specific date.
|
|
8
|
-
* @param {string} dateStr - The date string in "YYYY-MM-DD" format.
|
|
9
|
-
* @param {string} timeStr - Time string in "HH:MM" format.
|
|
10
|
-
* @param {string} timeZone - The IANA time zone identifier (not used, kept for compatibility).
|
|
11
|
-
* @returns {Date} Date object representing the time on the specified date.
|
|
12
|
-
*/
|
|
13
|
-
function parseDateTimeInTimeZone(dateStr, timeStr, timeZone) {
|
|
14
|
-
const [year, month, day] = dateStr.split('-').map(Number);
|
|
15
|
-
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
16
|
-
|
|
17
|
-
// Create a simple date object for the given date and time
|
|
18
|
-
// This represents the local time on that date
|
|
19
|
-
return new Date(year, month - 1, day, hours, minutes);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Gets the available time blocks or shifts for a reservation, considering 'uurOpVoorhand' and 'dagenInToekomst'.
|
|
24
|
-
* @param {Object} data - The main data object containing settings and meal information.
|
|
25
|
-
* @param {string} dateStr - The date string in "YYYY-MM-DD" format.
|
|
26
|
-
* @param {Array} reservations - An array of reservation objects.
|
|
27
|
-
* @param {number} guests - The number of guests for the reservation.
|
|
28
|
-
* @param {Array} blockedSlots - Optional array of blocked time slots to exclude.
|
|
29
|
-
* @param {string|null} giftcard - Optional giftcard to filter times by meal.
|
|
30
|
-
* @param {boolean} isAdmin - Optional flag to bypass time restrictions for admin users.
|
|
31
|
-
* @returns {Object} - Returns a pruned object of available time blocks or shifts, or an empty object if out of range.
|
|
32
|
-
*/
|
|
33
|
-
function getAvailableTimeblocks(data, dateStr, reservations, guests, blockedSlots = [], giftcard = null, isAdmin = false) {
|
|
34
|
-
// Get 'uurOpVoorhand' from general settings
|
|
35
|
-
let uurOpVoorhand = 0;
|
|
36
|
-
if (
|
|
37
|
-
data['general-settings'] &&
|
|
38
|
-
data['general-settings'].uurOpVoorhand &&
|
|
39
|
-
parseInt(data['general-settings'].uurOpVoorhand, 10) >= 0
|
|
40
|
-
) {
|
|
41
|
-
uurOpVoorhand = parseInt(data['general-settings'].uurOpVoorhand, 10);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Get 'dagenInToekomst' from general settings
|
|
45
|
-
let dagenInToekomst = 90; // Default if not defined
|
|
46
|
-
if (
|
|
47
|
-
data['general-settings'] &&
|
|
48
|
-
data['general-settings'].dagenInToekomst &&
|
|
49
|
-
parseInt(data['general-settings'].dagenInToekomst, 10) > 0
|
|
50
|
-
) {
|
|
51
|
-
dagenInToekomst = parseInt(data['general-settings'].dagenInToekomst, 10);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Time zone for CEST/CET (Europe/Amsterdam)
|
|
55
|
-
const timeZone = 'Europe/Amsterdam';
|
|
56
|
-
|
|
57
|
-
// Current date/time in local timezone (system time)
|
|
58
|
-
// Note: We assume the server is running in the correct timezone
|
|
59
|
-
const now = new Date();
|
|
60
|
-
const currentTimeInTimeZone = now;
|
|
61
|
-
|
|
62
|
-
// Calculate the maximum allowed date
|
|
63
|
-
const maxAllowedDate = new Date(currentTimeInTimeZone.getTime());
|
|
64
|
-
maxAllowedDate.setDate(maxAllowedDate.getDate() + dagenInToekomst);
|
|
65
|
-
maxAllowedDate.setHours(23, 59, 59, 999);
|
|
66
|
-
|
|
67
|
-
// Parse the target date (just the date part, no time)
|
|
68
|
-
const [year, month, day] = dateStr.split('-').map(Number);
|
|
69
|
-
const targetDateInTimeZone = new Date(year, month - 1, day);
|
|
70
|
-
|
|
71
|
-
// Check if targetDateInTimeZone is within dagenInToekomst (skip for admin)
|
|
72
|
-
if (!isAdmin && targetDateInTimeZone > maxAllowedDate) {
|
|
73
|
-
// Out of allowed range, return empty object
|
|
74
|
-
return {};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Check if the target date is today in the specified time zone
|
|
78
|
-
const isToday =
|
|
79
|
-
currentTimeInTimeZone.toDateString() === targetDateInTimeZone.toDateString();
|
|
80
|
-
|
|
81
|
-
// Get available time blocks or shifts
|
|
82
|
-
const availableTimeblocks = timeblocksAvailable(data, dateStr, reservations, guests, blockedSlots, giftcard, isAdmin);
|
|
83
|
-
|
|
84
|
-
// If the date is today and uurOpVoorhand is greater than zero, prune time blocks (skip for admin)
|
|
85
|
-
if (!isAdmin && isToday && uurOpVoorhand >= 0) {
|
|
86
|
-
const cutoffTime = new Date(currentTimeInTimeZone.getTime());
|
|
87
|
-
cutoffTime.setHours(cutoffTime.getHours() + uurOpVoorhand);
|
|
88
|
-
|
|
89
|
-
for (const [key, value] of Object.entries(availableTimeblocks)) {
|
|
90
|
-
let timeStr = key;
|
|
91
|
-
|
|
92
|
-
const timeBlockDateTime = parseDateTimeInTimeZone(dateStr, timeStr, timeZone);
|
|
93
|
-
|
|
94
|
-
if (timeBlockDateTime < cutoffTime) {
|
|
95
|
-
delete availableTimeblocks[key];
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Apply last booking time filter (lunchStop, dinerStop, ontbijtStop) - skip for admin
|
|
101
|
-
// IMPORTANT: This filter should only apply to TODAY, just like uurOpVoorhand
|
|
102
|
-
// The filter compares CURRENT TIME in Brussels against the stop time,
|
|
103
|
-
// not the reservation time slot against the stop time
|
|
104
|
-
if (!isAdmin && isToday) {
|
|
105
|
-
const settings = data?.['general-settings'] || {};
|
|
106
|
-
const breakfastStop = settings.ontbijtStop || null;
|
|
107
|
-
const lunchStop = settings.lunchStop || null;
|
|
108
|
-
const dinnerStop = settings.dinerStop || null;
|
|
109
|
-
|
|
110
|
-
// Only apply if at least one stop time is configured
|
|
111
|
-
if (breakfastStop || lunchStop || dinnerStop) {
|
|
112
|
-
// Get current time in Brussels timezone (in minutes since midnight)
|
|
113
|
-
// Using Intl.DateTimeFormat for reliable timezone conversion
|
|
114
|
-
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
115
|
-
timeZone: 'Europe/Brussels',
|
|
116
|
-
hour: '2-digit',
|
|
117
|
-
minute: '2-digit',
|
|
118
|
-
hour12: false,
|
|
119
|
-
});
|
|
120
|
-
const parts = formatter.formatToParts(now);
|
|
121
|
-
const timeParts = Object.fromEntries(parts.map(p => [p.type, p.value]));
|
|
122
|
-
const currentTimeMinutes = parseInt(timeParts.hour, 10) * 60 + parseInt(timeParts.minute, 10);
|
|
123
|
-
|
|
124
|
-
// Check if current time has passed any stop times
|
|
125
|
-
const breakfastStopMinutes = breakfastStop ? parseTime(breakfastStop) : null;
|
|
126
|
-
const lunchStopMinutes = lunchStop ? parseTime(lunchStop) : null;
|
|
127
|
-
const dinnerStopMinutes = dinnerStop ? parseTime(dinnerStop) : null;
|
|
128
|
-
|
|
129
|
-
const breakfastStopped = breakfastStopMinutes !== null && currentTimeMinutes >= breakfastStopMinutes;
|
|
130
|
-
const lunchStopped = lunchStopMinutes !== null && currentTimeMinutes >= lunchStopMinutes;
|
|
131
|
-
const dinnerStopped = dinnerStopMinutes !== null && currentTimeMinutes >= dinnerStopMinutes;
|
|
132
|
-
|
|
133
|
-
// Remove ALL slots of a meal type if current time >= stop time for that meal
|
|
134
|
-
for (const time in availableTimeblocks) {
|
|
135
|
-
const mealType = getMealTypeByTime(time);
|
|
136
|
-
let shouldRemove = false;
|
|
137
|
-
|
|
138
|
-
if (mealType === 'breakfast' && breakfastStopped) {
|
|
139
|
-
shouldRemove = true;
|
|
140
|
-
} else if (mealType === 'lunch' && lunchStopped) {
|
|
141
|
-
shouldRemove = true;
|
|
142
|
-
} else if (mealType === 'dinner' && dinnerStopped) {
|
|
143
|
-
shouldRemove = true;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (shouldRemove) {
|
|
147
|
-
delete availableTimeblocks[time];
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return availableTimeblocks;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
module.exports = {
|
|
157
|
-
getAvailableTimeblocks,
|
|
158
|
-
};
|
|
1
|
+
// getAvailableTimeblocks.js
|
|
2
|
+
|
|
3
|
+
const { timeblocksAvailable } = require('./processing/timeblocksAvailable');
|
|
4
|
+
const { parseTime, getMealTypeByTime } = require('./tableHelpers');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses a time string in "HH:MM" format into a Date object on a specific date.
|
|
8
|
+
* @param {string} dateStr - The date string in "YYYY-MM-DD" format.
|
|
9
|
+
* @param {string} timeStr - Time string in "HH:MM" format.
|
|
10
|
+
* @param {string} timeZone - The IANA time zone identifier (not used, kept for compatibility).
|
|
11
|
+
* @returns {Date} Date object representing the time on the specified date.
|
|
12
|
+
*/
|
|
13
|
+
function parseDateTimeInTimeZone(dateStr, timeStr, timeZone) {
|
|
14
|
+
const [year, month, day] = dateStr.split('-').map(Number);
|
|
15
|
+
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
16
|
+
|
|
17
|
+
// Create a simple date object for the given date and time
|
|
18
|
+
// This represents the local time on that date
|
|
19
|
+
return new Date(year, month - 1, day, hours, minutes);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gets the available time blocks or shifts for a reservation, considering 'uurOpVoorhand' and 'dagenInToekomst'.
|
|
24
|
+
* @param {Object} data - The main data object containing settings and meal information.
|
|
25
|
+
* @param {string} dateStr - The date string in "YYYY-MM-DD" format.
|
|
26
|
+
* @param {Array} reservations - An array of reservation objects.
|
|
27
|
+
* @param {number} guests - The number of guests for the reservation.
|
|
28
|
+
* @param {Array} blockedSlots - Optional array of blocked time slots to exclude.
|
|
29
|
+
* @param {string|null} giftcard - Optional giftcard to filter times by meal.
|
|
30
|
+
* @param {boolean} isAdmin - Optional flag to bypass time restrictions for admin users.
|
|
31
|
+
* @returns {Object} - Returns a pruned object of available time blocks or shifts, or an empty object if out of range.
|
|
32
|
+
*/
|
|
33
|
+
function getAvailableTimeblocks(data, dateStr, reservations, guests, blockedSlots = [], giftcard = null, isAdmin = false) {
|
|
34
|
+
// Get 'uurOpVoorhand' from general settings
|
|
35
|
+
let uurOpVoorhand = 0;
|
|
36
|
+
if (
|
|
37
|
+
data['general-settings'] &&
|
|
38
|
+
data['general-settings'].uurOpVoorhand &&
|
|
39
|
+
parseInt(data['general-settings'].uurOpVoorhand, 10) >= 0
|
|
40
|
+
) {
|
|
41
|
+
uurOpVoorhand = parseInt(data['general-settings'].uurOpVoorhand, 10);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Get 'dagenInToekomst' from general settings
|
|
45
|
+
let dagenInToekomst = 90; // Default if not defined
|
|
46
|
+
if (
|
|
47
|
+
data['general-settings'] &&
|
|
48
|
+
data['general-settings'].dagenInToekomst &&
|
|
49
|
+
parseInt(data['general-settings'].dagenInToekomst, 10) > 0
|
|
50
|
+
) {
|
|
51
|
+
dagenInToekomst = parseInt(data['general-settings'].dagenInToekomst, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Time zone for CEST/CET (Europe/Amsterdam)
|
|
55
|
+
const timeZone = 'Europe/Amsterdam';
|
|
56
|
+
|
|
57
|
+
// Current date/time in local timezone (system time)
|
|
58
|
+
// Note: We assume the server is running in the correct timezone
|
|
59
|
+
const now = new Date();
|
|
60
|
+
const currentTimeInTimeZone = now;
|
|
61
|
+
|
|
62
|
+
// Calculate the maximum allowed date
|
|
63
|
+
const maxAllowedDate = new Date(currentTimeInTimeZone.getTime());
|
|
64
|
+
maxAllowedDate.setDate(maxAllowedDate.getDate() + dagenInToekomst);
|
|
65
|
+
maxAllowedDate.setHours(23, 59, 59, 999);
|
|
66
|
+
|
|
67
|
+
// Parse the target date (just the date part, no time)
|
|
68
|
+
const [year, month, day] = dateStr.split('-').map(Number);
|
|
69
|
+
const targetDateInTimeZone = new Date(year, month - 1, day);
|
|
70
|
+
|
|
71
|
+
// Check if targetDateInTimeZone is within dagenInToekomst (skip for admin)
|
|
72
|
+
if (!isAdmin && targetDateInTimeZone > maxAllowedDate) {
|
|
73
|
+
// Out of allowed range, return empty object
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if the target date is today in the specified time zone
|
|
78
|
+
const isToday =
|
|
79
|
+
currentTimeInTimeZone.toDateString() === targetDateInTimeZone.toDateString();
|
|
80
|
+
|
|
81
|
+
// Get available time blocks or shifts
|
|
82
|
+
const availableTimeblocks = timeblocksAvailable(data, dateStr, reservations, guests, blockedSlots, giftcard, isAdmin);
|
|
83
|
+
|
|
84
|
+
// If the date is today and uurOpVoorhand is greater than zero, prune time blocks (skip for admin)
|
|
85
|
+
if (!isAdmin && isToday && uurOpVoorhand >= 0) {
|
|
86
|
+
const cutoffTime = new Date(currentTimeInTimeZone.getTime());
|
|
87
|
+
cutoffTime.setHours(cutoffTime.getHours() + uurOpVoorhand);
|
|
88
|
+
|
|
89
|
+
for (const [key, value] of Object.entries(availableTimeblocks)) {
|
|
90
|
+
let timeStr = key;
|
|
91
|
+
|
|
92
|
+
const timeBlockDateTime = parseDateTimeInTimeZone(dateStr, timeStr, timeZone);
|
|
93
|
+
|
|
94
|
+
if (timeBlockDateTime < cutoffTime) {
|
|
95
|
+
delete availableTimeblocks[key];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Apply last booking time filter (lunchStop, dinerStop, ontbijtStop) - skip for admin
|
|
101
|
+
// IMPORTANT: This filter should only apply to TODAY, just like uurOpVoorhand
|
|
102
|
+
// The filter compares CURRENT TIME in Brussels against the stop time,
|
|
103
|
+
// not the reservation time slot against the stop time
|
|
104
|
+
if (!isAdmin && isToday) {
|
|
105
|
+
const settings = data?.['general-settings'] || {};
|
|
106
|
+
const breakfastStop = settings.ontbijtStop || null;
|
|
107
|
+
const lunchStop = settings.lunchStop || null;
|
|
108
|
+
const dinnerStop = settings.dinerStop || null;
|
|
109
|
+
|
|
110
|
+
// Only apply if at least one stop time is configured
|
|
111
|
+
if (breakfastStop || lunchStop || dinnerStop) {
|
|
112
|
+
// Get current time in Brussels timezone (in minutes since midnight)
|
|
113
|
+
// Using Intl.DateTimeFormat for reliable timezone conversion
|
|
114
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
115
|
+
timeZone: 'Europe/Brussels',
|
|
116
|
+
hour: '2-digit',
|
|
117
|
+
minute: '2-digit',
|
|
118
|
+
hour12: false,
|
|
119
|
+
});
|
|
120
|
+
const parts = formatter.formatToParts(now);
|
|
121
|
+
const timeParts = Object.fromEntries(parts.map(p => [p.type, p.value]));
|
|
122
|
+
const currentTimeMinutes = parseInt(timeParts.hour, 10) * 60 + parseInt(timeParts.minute, 10);
|
|
123
|
+
|
|
124
|
+
// Check if current time has passed any stop times
|
|
125
|
+
const breakfastStopMinutes = breakfastStop ? parseTime(breakfastStop) : null;
|
|
126
|
+
const lunchStopMinutes = lunchStop ? parseTime(lunchStop) : null;
|
|
127
|
+
const dinnerStopMinutes = dinnerStop ? parseTime(dinnerStop) : null;
|
|
128
|
+
|
|
129
|
+
const breakfastStopped = breakfastStopMinutes !== null && currentTimeMinutes >= breakfastStopMinutes;
|
|
130
|
+
const lunchStopped = lunchStopMinutes !== null && currentTimeMinutes >= lunchStopMinutes;
|
|
131
|
+
const dinnerStopped = dinnerStopMinutes !== null && currentTimeMinutes >= dinnerStopMinutes;
|
|
132
|
+
|
|
133
|
+
// Remove ALL slots of a meal type if current time >= stop time for that meal
|
|
134
|
+
for (const time in availableTimeblocks) {
|
|
135
|
+
const mealType = getMealTypeByTime(time);
|
|
136
|
+
let shouldRemove = false;
|
|
137
|
+
|
|
138
|
+
if (mealType === 'breakfast' && breakfastStopped) {
|
|
139
|
+
shouldRemove = true;
|
|
140
|
+
} else if (mealType === 'lunch' && lunchStopped) {
|
|
141
|
+
shouldRemove = true;
|
|
142
|
+
} else if (mealType === 'dinner' && dinnerStopped) {
|
|
143
|
+
shouldRemove = true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (shouldRemove) {
|
|
147
|
+
delete availableTimeblocks[time];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return availableTimeblocks;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
getAvailableTimeblocks,
|
|
158
|
+
};
|
package/grouping.js
CHANGED
|
@@ -1,162 +1,162 @@
|
|
|
1
|
-
// grouping.js
|
|
2
|
-
/**
|
|
3
|
-
* Try to allocate a reservation via pinned groups.
|
|
4
|
-
* Returns { tables: number[], tableIds: string[], viaGroup: string } on success,
|
|
5
|
-
* null if grouping is enabled but no match (and strict=false),
|
|
6
|
-
* and throws if grouping is strict and no group matches.
|
|
7
|
-
*
|
|
8
|
-
* Dependencies (passed in): isTemporaryTableValid, isTableFreeForAllSlots
|
|
9
|
-
*/
|
|
10
|
-
function tryGroupTables({
|
|
11
|
-
restaurantSettings,
|
|
12
|
-
allTables,
|
|
13
|
-
guests,
|
|
14
|
-
date,
|
|
15
|
-
time,
|
|
16
|
-
requiredSlots,
|
|
17
|
-
tableOccupiedSlots,
|
|
18
|
-
isTemporaryTableValid,
|
|
19
|
-
isTableFreeForAllSlots,
|
|
20
|
-
}) {
|
|
21
|
-
const gset = restaurantSettings["grouping-settings"] || {};
|
|
22
|
-
const groupingEnabled = gset.enable === true;
|
|
23
|
-
const groupingStrict = gset.strict === true;
|
|
24
|
-
const maxSubtablesPerGroup = Number.isFinite(gset.maxSubtables)
|
|
25
|
-
? gset.maxSubtables
|
|
26
|
-
: undefined;
|
|
27
|
-
const orderStrategy = gset.orderStrategy || "smallest-first"; // 'smallest-first' | 'capacity-asc' | 'closest-fit'
|
|
28
|
-
const seed = gset.seed ?? 0;
|
|
29
|
-
|
|
30
|
-
const pinnedGroups = Array.isArray(restaurantSettings.pinnedGroups)
|
|
31
|
-
? restaurantSettings.pinnedGroups
|
|
32
|
-
: [];
|
|
33
|
-
|
|
34
|
-
if (!groupingEnabled || pinnedGroups.length === 0) {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Build lookups from allTables
|
|
39
|
-
const byId = new Map(allTables.map((t) => [String(t.tableId), t]));
|
|
40
|
-
const byNumber = new Map(
|
|
41
|
-
allTables
|
|
42
|
-
.filter((t) => typeof t.tableNumber === "number")
|
|
43
|
-
.map((t) => [t.tableNumber, t])
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
// Quick check: is there already a single-table fit?
|
|
47
|
-
const hasSingleFit = allTables.some(
|
|
48
|
-
(t) =>
|
|
49
|
-
t.minCapacity <= guests &&
|
|
50
|
-
guests <= t.maxCapacity &&
|
|
51
|
-
isTemporaryTableValid(t, date, time) &&
|
|
52
|
-
isTableFreeForAllSlots(t.tableNumber, requiredSlots, tableOccupiedSlots)
|
|
53
|
-
);
|
|
54
|
-
if (hasSingleFit) {
|
|
55
|
-
// Let caller handle single-table path; grouping is only used when no single table fits
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Stable group ordering
|
|
60
|
-
const stableGroups = [...pinnedGroups].sort((a, b) => {
|
|
61
|
-
const aid = String(a.id || a.name || "");
|
|
62
|
-
const bid = String(b.id || b.name || "");
|
|
63
|
-
return aid.localeCompare(bid);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
// Deterministic RNG (kept for parity; not used by default)
|
|
67
|
-
const rnd = (() => {
|
|
68
|
-
let x = Math.imul(0x9e3779b1, (seed | 0) + 0x7f4a7c15) | 0;
|
|
69
|
-
return () => (
|
|
70
|
-
(x = (x ^ (x >>> 15)) * (x | 1)),
|
|
71
|
-
(x ^= x + Math.imul(x ^ (x >>> 7), x | 61)),
|
|
72
|
-
((x ^ (x >>> 14)) >>> 0) / 4294967296
|
|
73
|
-
);
|
|
74
|
-
})();
|
|
75
|
-
|
|
76
|
-
// Subtable ordering strategy
|
|
77
|
-
const subSort = (a, b) => {
|
|
78
|
-
if (orderStrategy === "closest-fit") {
|
|
79
|
-
const slA = (a.maxCapacity || 0) - (a.minCapacity || 0);
|
|
80
|
-
const slB = (b.maxCapacity || 0) - (b.minCapacity || 0);
|
|
81
|
-
if (slA !== slB) return slA - slB;
|
|
82
|
-
}
|
|
83
|
-
if (a.maxCapacity !== b.maxCapacity) return a.maxCapacity - b.maxCapacity;
|
|
84
|
-
if (a.tableNumber !== b.tableNumber) return a.tableNumber - b.tableNumber;
|
|
85
|
-
const ida = String(a.tableId),
|
|
86
|
-
idb = String(b.tableId);
|
|
87
|
-
return ida.localeCompare(idb);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
// Try each pinned group
|
|
91
|
-
for (const g of stableGroups) {
|
|
92
|
-
const rawIds = Array.isArray(g.tableIds) ? g.tableIds.map(String) : [];
|
|
93
|
-
const rawNums = Array.isArray(g.tableNumbers)
|
|
94
|
-
? g.tableNumbers.filter((n) => typeof n === "number")
|
|
95
|
-
: [];
|
|
96
|
-
|
|
97
|
-
let subs = [];
|
|
98
|
-
if (rawIds.length) subs = rawIds.map((id) => byId.get(id)).filter(Boolean);
|
|
99
|
-
else if (rawNums.length)
|
|
100
|
-
subs = rawNums.map((n) => byNumber.get(n)).filter(Boolean);
|
|
101
|
-
|
|
102
|
-
if (maxSubtablesPerGroup && subs.length > maxSubtablesPerGroup) {
|
|
103
|
-
subs = subs.slice(0, maxSubtablesPerGroup);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (subs.length < 2) continue;
|
|
107
|
-
|
|
108
|
-
// All subtables must be valid & free for all required slots
|
|
109
|
-
if (
|
|
110
|
-
!subs.every(
|
|
111
|
-
(t) =>
|
|
112
|
-
isTemporaryTableValid(t, date, time) &&
|
|
113
|
-
isTableFreeForAllSlots(
|
|
114
|
-
t.tableNumber,
|
|
115
|
-
requiredSlots,
|
|
116
|
-
tableOccupiedSlots
|
|
117
|
-
)
|
|
118
|
-
)
|
|
119
|
-
) {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Capacity checks
|
|
124
|
-
const totalMax = subs.reduce((s, t) => s + (t.maxCapacity || 0), 0);
|
|
125
|
-
const totalMin = subs.reduce((s, t) => s + (t.minCapacity || 0), 0);
|
|
126
|
-
if (guests < totalMin) continue;
|
|
127
|
-
if (guests > totalMax) continue;
|
|
128
|
-
|
|
129
|
-
// Deterministic allocation:
|
|
130
|
-
const ordered = [...subs].sort(subSort);
|
|
131
|
-
const alloc = new Map(
|
|
132
|
-
ordered.map((t) => [t.tableNumber, Math.max(0, t.minCapacity || 0)])
|
|
133
|
-
);
|
|
134
|
-
let remaining = guests - [...alloc.values()].reduce((s, v) => s + v, 0);
|
|
135
|
-
if (remaining < 0) continue;
|
|
136
|
-
|
|
137
|
-
for (const t of ordered) {
|
|
138
|
-
if (remaining <= 0) break;
|
|
139
|
-
const cur = alloc.get(t.tableNumber);
|
|
140
|
-
const addable = Math.max(0, (t.maxCapacity || 0) - cur);
|
|
141
|
-
const give = Math.min(addable, remaining);
|
|
142
|
-
alloc.set(t.tableNumber, cur + give);
|
|
143
|
-
remaining -= give;
|
|
144
|
-
}
|
|
145
|
-
if (remaining !== 0) continue; // couldn’t fit exactly
|
|
146
|
-
|
|
147
|
-
// Success
|
|
148
|
-
return {
|
|
149
|
-
tables: ordered.map((t) => t.tableNumber),
|
|
150
|
-
tableIds: ordered.map((t) => t.tableId),
|
|
151
|
-
viaGroup: String(g.id || g.name || "pinned-group"),
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// No match
|
|
156
|
-
if (groupingStrict) {
|
|
157
|
-
throw new Error("No matching pinned group found (strict grouping)");
|
|
158
|
-
}
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
module.exports = { tryGroupTables };
|
|
1
|
+
// grouping.js
|
|
2
|
+
/**
|
|
3
|
+
* Try to allocate a reservation via pinned groups.
|
|
4
|
+
* Returns { tables: number[], tableIds: string[], viaGroup: string } on success,
|
|
5
|
+
* null if grouping is enabled but no match (and strict=false),
|
|
6
|
+
* and throws if grouping is strict and no group matches.
|
|
7
|
+
*
|
|
8
|
+
* Dependencies (passed in): isTemporaryTableValid, isTableFreeForAllSlots
|
|
9
|
+
*/
|
|
10
|
+
function tryGroupTables({
|
|
11
|
+
restaurantSettings,
|
|
12
|
+
allTables,
|
|
13
|
+
guests,
|
|
14
|
+
date,
|
|
15
|
+
time,
|
|
16
|
+
requiredSlots,
|
|
17
|
+
tableOccupiedSlots,
|
|
18
|
+
isTemporaryTableValid,
|
|
19
|
+
isTableFreeForAllSlots,
|
|
20
|
+
}) {
|
|
21
|
+
const gset = restaurantSettings["grouping-settings"] || {};
|
|
22
|
+
const groupingEnabled = gset.enable === true;
|
|
23
|
+
const groupingStrict = gset.strict === true;
|
|
24
|
+
const maxSubtablesPerGroup = Number.isFinite(gset.maxSubtables)
|
|
25
|
+
? gset.maxSubtables
|
|
26
|
+
: undefined;
|
|
27
|
+
const orderStrategy = gset.orderStrategy || "smallest-first"; // 'smallest-first' | 'capacity-asc' | 'closest-fit'
|
|
28
|
+
const seed = gset.seed ?? 0;
|
|
29
|
+
|
|
30
|
+
const pinnedGroups = Array.isArray(restaurantSettings.pinnedGroups)
|
|
31
|
+
? restaurantSettings.pinnedGroups
|
|
32
|
+
: [];
|
|
33
|
+
|
|
34
|
+
if (!groupingEnabled || pinnedGroups.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build lookups from allTables
|
|
39
|
+
const byId = new Map(allTables.map((t) => [String(t.tableId), t]));
|
|
40
|
+
const byNumber = new Map(
|
|
41
|
+
allTables
|
|
42
|
+
.filter((t) => typeof t.tableNumber === "number")
|
|
43
|
+
.map((t) => [t.tableNumber, t])
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Quick check: is there already a single-table fit?
|
|
47
|
+
const hasSingleFit = allTables.some(
|
|
48
|
+
(t) =>
|
|
49
|
+
t.minCapacity <= guests &&
|
|
50
|
+
guests <= t.maxCapacity &&
|
|
51
|
+
isTemporaryTableValid(t, date, time) &&
|
|
52
|
+
isTableFreeForAllSlots(t.tableNumber, requiredSlots, tableOccupiedSlots)
|
|
53
|
+
);
|
|
54
|
+
if (hasSingleFit) {
|
|
55
|
+
// Let caller handle single-table path; grouping is only used when no single table fits
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Stable group ordering
|
|
60
|
+
const stableGroups = [...pinnedGroups].sort((a, b) => {
|
|
61
|
+
const aid = String(a.id || a.name || "");
|
|
62
|
+
const bid = String(b.id || b.name || "");
|
|
63
|
+
return aid.localeCompare(bid);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Deterministic RNG (kept for parity; not used by default)
|
|
67
|
+
const rnd = (() => {
|
|
68
|
+
let x = Math.imul(0x9e3779b1, (seed | 0) + 0x7f4a7c15) | 0;
|
|
69
|
+
return () => (
|
|
70
|
+
(x = (x ^ (x >>> 15)) * (x | 1)),
|
|
71
|
+
(x ^= x + Math.imul(x ^ (x >>> 7), x | 61)),
|
|
72
|
+
((x ^ (x >>> 14)) >>> 0) / 4294967296
|
|
73
|
+
);
|
|
74
|
+
})();
|
|
75
|
+
|
|
76
|
+
// Subtable ordering strategy
|
|
77
|
+
const subSort = (a, b) => {
|
|
78
|
+
if (orderStrategy === "closest-fit") {
|
|
79
|
+
const slA = (a.maxCapacity || 0) - (a.minCapacity || 0);
|
|
80
|
+
const slB = (b.maxCapacity || 0) - (b.minCapacity || 0);
|
|
81
|
+
if (slA !== slB) return slA - slB;
|
|
82
|
+
}
|
|
83
|
+
if (a.maxCapacity !== b.maxCapacity) return a.maxCapacity - b.maxCapacity;
|
|
84
|
+
if (a.tableNumber !== b.tableNumber) return a.tableNumber - b.tableNumber;
|
|
85
|
+
const ida = String(a.tableId),
|
|
86
|
+
idb = String(b.tableId);
|
|
87
|
+
return ida.localeCompare(idb);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Try each pinned group
|
|
91
|
+
for (const g of stableGroups) {
|
|
92
|
+
const rawIds = Array.isArray(g.tableIds) ? g.tableIds.map(String) : [];
|
|
93
|
+
const rawNums = Array.isArray(g.tableNumbers)
|
|
94
|
+
? g.tableNumbers.filter((n) => typeof n === "number")
|
|
95
|
+
: [];
|
|
96
|
+
|
|
97
|
+
let subs = [];
|
|
98
|
+
if (rawIds.length) subs = rawIds.map((id) => byId.get(id)).filter(Boolean);
|
|
99
|
+
else if (rawNums.length)
|
|
100
|
+
subs = rawNums.map((n) => byNumber.get(n)).filter(Boolean);
|
|
101
|
+
|
|
102
|
+
if (maxSubtablesPerGroup && subs.length > maxSubtablesPerGroup) {
|
|
103
|
+
subs = subs.slice(0, maxSubtablesPerGroup);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (subs.length < 2) continue;
|
|
107
|
+
|
|
108
|
+
// All subtables must be valid & free for all required slots
|
|
109
|
+
if (
|
|
110
|
+
!subs.every(
|
|
111
|
+
(t) =>
|
|
112
|
+
isTemporaryTableValid(t, date, time) &&
|
|
113
|
+
isTableFreeForAllSlots(
|
|
114
|
+
t.tableNumber,
|
|
115
|
+
requiredSlots,
|
|
116
|
+
tableOccupiedSlots
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Capacity checks
|
|
124
|
+
const totalMax = subs.reduce((s, t) => s + (t.maxCapacity || 0), 0);
|
|
125
|
+
const totalMin = subs.reduce((s, t) => s + (t.minCapacity || 0), 0);
|
|
126
|
+
if (guests < totalMin) continue;
|
|
127
|
+
if (guests > totalMax) continue;
|
|
128
|
+
|
|
129
|
+
// Deterministic allocation:
|
|
130
|
+
const ordered = [...subs].sort(subSort);
|
|
131
|
+
const alloc = new Map(
|
|
132
|
+
ordered.map((t) => [t.tableNumber, Math.max(0, t.minCapacity || 0)])
|
|
133
|
+
);
|
|
134
|
+
let remaining = guests - [...alloc.values()].reduce((s, v) => s + v, 0);
|
|
135
|
+
if (remaining < 0) continue;
|
|
136
|
+
|
|
137
|
+
for (const t of ordered) {
|
|
138
|
+
if (remaining <= 0) break;
|
|
139
|
+
const cur = alloc.get(t.tableNumber);
|
|
140
|
+
const addable = Math.max(0, (t.maxCapacity || 0) - cur);
|
|
141
|
+
const give = Math.min(addable, remaining);
|
|
142
|
+
alloc.set(t.tableNumber, cur + give);
|
|
143
|
+
remaining -= give;
|
|
144
|
+
}
|
|
145
|
+
if (remaining !== 0) continue; // couldn’t fit exactly
|
|
146
|
+
|
|
147
|
+
// Success
|
|
148
|
+
return {
|
|
149
|
+
tables: ordered.map((t) => t.tableNumber),
|
|
150
|
+
tableIds: ordered.map((t) => t.tableId),
|
|
151
|
+
viaGroup: String(g.id || g.name || "pinned-group"),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// No match
|
|
156
|
+
if (groupingStrict) {
|
|
157
|
+
throw new Error("No matching pinned group found (strict grouping)");
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { tryGroupTables };
|