@happychef/algorithm 1.0.0
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/assignTables.js +398 -0
- package/filters/maxArrivalsFilter.js +115 -0
- package/filters/maxGroupsFilter.js +222 -0
- package/filters/timeFilter.js +90 -0
- package/getAvailableTimeblocks.js +109 -0
- package/grouping.js +162 -0
- package/index.js +47 -0
- package/isDateAvailable.js +77 -0
- package/isDateAvailableWithTableCheck.js +169 -0
- package/isTimeAvailable.js +22 -0
- package/package.json +18 -0
- package/processing/dailyGuestCounts.js +73 -0
- package/processing/mealTypeCount.js +133 -0
- package/processing/timeblocksAvailable.js +120 -0
- package/reservation_data/counter.js +65 -0
- package/restaurant_data/exceptions.js +149 -0
- package/restaurant_data/openinghours.js +123 -0
- package/simulateTableAssignment.js +594 -0
- package/tableHelpers.js +178 -0
- package/test/test_counter.js +194 -0
- package/test/test_dailyCount.js +81 -0
- package/test/test_datesAvailable.js +106 -0
- package/test/test_exceptions.js +173 -0
- package/test/test_isDateAvailable.js +330 -0
- package/test/test_mealTypeCount.js +54 -0
- package/test/test_timesAvailable.js +88 -0
- package/test.js +336 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// src/Pages/NewReservation/StepOne/algorithm/filters/maxGroupsFilter.js
|
|
2
|
+
// Import the dedicated getMealType function instead of relying on the openinghours version
|
|
3
|
+
// This ensures consistency in meal type determination
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Determines the meal type (breakfast, lunch, dinner) based on the time.
|
|
7
|
+
* @param {string} time - Time in HH:MM format
|
|
8
|
+
* @returns {string|null} - Meal type or null if outside meal times
|
|
9
|
+
*/
|
|
10
|
+
function getMealType(time) {
|
|
11
|
+
const hour = parseInt(time.split(':')[0], 10);
|
|
12
|
+
|
|
13
|
+
if (hour >= 4 && hour < 11) return 'breakfast';
|
|
14
|
+
if (hour >= 11 && hour < 16) return 'lunch';
|
|
15
|
+
if (hour >= 16 && hour < 23) return 'dinner';
|
|
16
|
+
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// --- Add console logs for debugging ---
|
|
21
|
+
// Set DEBUG_MAX_GROUPS to true in your development environment to enable detailed logs
|
|
22
|
+
const DEBUG_MAX_GROUPS = true; // Set to true to enable detailed logs for this filter
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Helper function for conditional logging.
|
|
26
|
+
* @param {string} message - The log message.
|
|
27
|
+
* @param {...any} args - Additional arguments to log.
|
|
28
|
+
*/
|
|
29
|
+
function logDebug(message, ...args) {
|
|
30
|
+
if (DEBUG_MAX_GROUPS) {
|
|
31
|
+
// Using console.debug might allow filtering in browser dev tools,
|
|
32
|
+
// but console.log ensures visibility.
|
|
33
|
+
console.log(`[MaxGroupsFilter] ${message}`, ...args);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// --- End log setup ---
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extracts a number value from various data formats (e.g., MongoDB NumberInt).
|
|
40
|
+
* Handles null, undefined, object with $numberInt, number, and string representations.
|
|
41
|
+
* Returns null if the value is not a valid integer representation.
|
|
42
|
+
* @param {*} value - Value from data object.
|
|
43
|
+
* @returns {number|null} - Parsed integer or null if invalid.
|
|
44
|
+
*/
|
|
45
|
+
function extractNumber(value) {
|
|
46
|
+
if (value === null || value === undefined) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Handle MongoDB NumberInt format: { "$numberInt": "1" }
|
|
51
|
+
if (typeof value === 'object' && value !== null && '$numberInt' in value) {
|
|
52
|
+
const parsed = parseInt(value.$numberInt, 10);
|
|
53
|
+
return isNaN(parsed) ? null : parsed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Handle regular number type
|
|
57
|
+
if (typeof value === 'number') {
|
|
58
|
+
// Ensure it's an integer, not float/Infinity/NaN
|
|
59
|
+
return Number.isInteger(value) ? value : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Handle string number: "1"
|
|
63
|
+
if (typeof value === 'string') {
|
|
64
|
+
const parsed = parseInt(value, 10);
|
|
65
|
+
// Ensure the string was fully parsed as a number and is finite
|
|
66
|
+
return isNaN(parsed) ? null : parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Not a recognized number format
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Filters available timeblocks based on maximum group size limits for each meal period.
|
|
75
|
+
* It checks if adding a new reservation of 'guests' count would exceed the configured
|
|
76
|
+
* maximum number of groups allowed for various size thresholds within the specific meal period.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} restaurantData - The main restaurant data object, expected to contain keys like 'max-groups-breakfast', 'max-groups-lunch', etc.
|
|
79
|
+
* @param {string} dateStr - The date string in "YYYY-MM-DD" format.
|
|
80
|
+
* @param {Object} timeblocks - An object of available timeblocks { "HH:MM": timeData } that have passed previous filters.
|
|
81
|
+
* @param {Array} reservations - An array of existing reservation objects for potentially multiple dates.
|
|
82
|
+
* @param {number|string} guests - The number of guests for the new reservation being considered.
|
|
83
|
+
* @returns {Object} - A new object containing only the timeblocks that satisfy the max group limits.
|
|
84
|
+
*/
|
|
85
|
+
function filterTimeblocksByMaxGroups(restaurantData, dateStr, timeblocks, reservations, guests) {
|
|
86
|
+
logDebug(`--- Running MaxGroupsFilter for Date: ${dateStr}, Guests: ${guests} ---`);
|
|
87
|
+
// Log the top-level keys related to max-groups to see what's actually present
|
|
88
|
+
const groupKeys = Object.keys(restaurantData).filter(k => k.startsWith('max-groups-'));
|
|
89
|
+
logDebug("Received restaurantData max-group keys:", groupKeys.length > 0 ? groupKeys : 'None found');
|
|
90
|
+
|
|
91
|
+
const filteredBlocks = {}; // Initialize the object to store timeblocks that pass the filter
|
|
92
|
+
const guestsNumber = parseInt(guests, 10); // Ensure guest count is a number
|
|
93
|
+
|
|
94
|
+
// Validate guest input
|
|
95
|
+
if (isNaN(guestsNumber) || guestsNumber <= 0) {
|
|
96
|
+
logDebug(`Invalid guest count (${guests}). Returning all ${Object.keys(timeblocks).length} input blocks unfiltered.`);
|
|
97
|
+
return timeblocks; // Return original blocks if guest count is invalid
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Pre-filter reservations to only those on the specific date for efficiency
|
|
101
|
+
const reservationsOnDate = reservations.filter(r => r.date === dateStr);
|
|
102
|
+
logDebug(`Relevant reservations on ${dateStr}: ${reservationsOnDate.length}`);
|
|
103
|
+
logDebug(`Input timeblocks count: ${Object.keys(timeblocks).length}`);
|
|
104
|
+
|
|
105
|
+
// Group reservations by meal type using our consistent getMealType function
|
|
106
|
+
const reservationsByMealType = {};
|
|
107
|
+
reservationsOnDate.forEach(r => {
|
|
108
|
+
const mealType = getMealType(r.time);
|
|
109
|
+
if (!mealType) return; // Skip if no meal type determined
|
|
110
|
+
|
|
111
|
+
if (!reservationsByMealType[mealType]) {
|
|
112
|
+
reservationsByMealType[mealType] = [];
|
|
113
|
+
}
|
|
114
|
+
reservationsByMealType[mealType].push(r);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
logDebug('Reservations grouped by meal type:', reservationsByMealType);
|
|
118
|
+
|
|
119
|
+
// Iterate through each timeblock that needs checking
|
|
120
|
+
for (const [time, timeData] of Object.entries(timeblocks)) {
|
|
121
|
+
const mealType = getMealType(time); // Determine meal period (e.g., 'breakfast') using our consistent function
|
|
122
|
+
logDebug(`\nChecking Time: ${time} -> Determined Meal: ${mealType}`);
|
|
123
|
+
|
|
124
|
+
// If the time doesn't fall into a recognized meal period, the filter doesn't apply
|
|
125
|
+
if (!mealType) {
|
|
126
|
+
logDebug(` Time ${time} has no associated meal type. Keeping block.`);
|
|
127
|
+
filteredBlocks[time] = timeData;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Construct the key for the settings specific to this meal (e.g., 'max-groups-breakfast')
|
|
132
|
+
const maxGroupSettingsKey = `max-groups-${mealType}`;
|
|
133
|
+
// Retrieve the settings object for this meal from the restaurant data
|
|
134
|
+
const maxGroupSettings = restaurantData[maxGroupSettingsKey];
|
|
135
|
+
|
|
136
|
+
logDebug(` Looking for settings using key: "${maxGroupSettingsKey}"`);
|
|
137
|
+
// If no settings object exists for this meal type, the filter doesn't apply to this timeblock
|
|
138
|
+
if (!maxGroupSettings || typeof maxGroupSettings !== 'object') {
|
|
139
|
+
logDebug(` No settings found for key "${maxGroupSettingsKey}". Filter doesn't apply. Keeping block.`);
|
|
140
|
+
filteredBlocks[time] = timeData;
|
|
141
|
+
continue; // Move to the next timeblock
|
|
142
|
+
}
|
|
143
|
+
// Log the actual settings found for this meal to verify correctness
|
|
144
|
+
logDebug(` Found settings for ${mealType}:`, JSON.stringify(maxGroupSettings));
|
|
145
|
+
|
|
146
|
+
// --- Settings exist, now apply the filter logic ---
|
|
147
|
+
let isTimeAllowed = true; // Assume the timeblock is allowed until a limit is violated
|
|
148
|
+
|
|
149
|
+
// Extract and sort the numeric group size limits defined in the settings (e.g., "6", "7" -> [6, 7])
|
|
150
|
+
const limitSizes = Object.keys(maxGroupSettings)
|
|
151
|
+
.map(key => parseInt(key, 10)) // Convert keys to numbers
|
|
152
|
+
.filter(num => !isNaN(num) && num > 0) // Keep only positive integers
|
|
153
|
+
.sort((a, b) => a - b); // Sort numerically (e.g., check 6 before 7)
|
|
154
|
+
logDebug(` Defined numeric limit sizes to check for ${mealType}: [${limitSizes.join(', ')}]`);
|
|
155
|
+
|
|
156
|
+
// Check against each defined limit size threshold
|
|
157
|
+
for (const limitSize of limitSizes) {
|
|
158
|
+
const limitSizeStr = String(limitSize); // Original string key (e.g., "6")
|
|
159
|
+
logDebug(` Checking Limit Condition: Groups of size >= ${limitSize}`);
|
|
160
|
+
|
|
161
|
+
// Does the new booking we're considering (guestsNumber) meet or exceed this limit threshold?
|
|
162
|
+
// Example: If limitSize is 6, and guestsNumber is 7, this limit applies. If guestsNumber is 5, it doesn't.
|
|
163
|
+
if (guestsNumber >= limitSize) {
|
|
164
|
+
logDebug(` Applicable: Booking guests (${guestsNumber}) >= Limit threshold (${limitSize})`);
|
|
165
|
+
|
|
166
|
+
// Extract the maximum number of groups allowed for this threshold
|
|
167
|
+
const maxAllowedCount = extractNumber(maxGroupSettings[limitSizeStr]);
|
|
168
|
+
logDebug(` Max groups allowed for >=${limitSize} is: ${maxAllowedCount} (raw setting: ${JSON.stringify(maxGroupSettings[limitSizeStr])})`);
|
|
169
|
+
|
|
170
|
+
// If the setting for this limit size is invalid (null or negative), treat it as 'no limit'
|
|
171
|
+
if (maxAllowedCount === null || maxAllowedCount < 0) {
|
|
172
|
+
logDebug(` Skipping check: Invalid or non-positive max count defined for size ${limitSize}.`);
|
|
173
|
+
continue; // Skip to the next limitSize
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Get reservations for this meal type - using only reservations from the same meal type
|
|
177
|
+
const reservationsForMealType = reservationsByMealType[mealType] || [];
|
|
178
|
+
|
|
179
|
+
// Count how many existing reservations for this meal type meet or exceed this limit size
|
|
180
|
+
const existingMatchingGroupsCount = reservationsForMealType.filter(reservation => {
|
|
181
|
+
const reservationGuests = parseInt(reservation.guests, 10);
|
|
182
|
+
return !isNaN(reservationGuests) && reservationGuests >= limitSize;
|
|
183
|
+
}).length;
|
|
184
|
+
|
|
185
|
+
logDebug(` Count of existing groups >=${limitSize} found in ${mealType}: ${existingMatchingGroupsCount}`);
|
|
186
|
+
|
|
187
|
+
// THE CORE LOGIC: Check if adding the new group would exceed the max allowed count
|
|
188
|
+
if (existingMatchingGroupsCount + 1 > maxAllowedCount) {
|
|
189
|
+
logDebug(` *** VIOLATION DETECTED *** for limit >=${limitSize}.`);
|
|
190
|
+
logDebug(` (Existing Groups: ${existingMatchingGroupsCount} + New Group: 1 = ${existingMatchingGroupsCount + 1}, which is > Max Allowed: ${maxAllowedCount})`);
|
|
191
|
+
logDebug(` Blocking time ${time} due to this violation.`);
|
|
192
|
+
isTimeAllowed = false; // Mark the timeblock as blocked
|
|
193
|
+
break; // No need to check further limits for this timeblock; it has already failed.
|
|
194
|
+
} else {
|
|
195
|
+
// This specific limit check passed
|
|
196
|
+
logDebug(` OK: Limit for >=${limitSize} not exceeded. (Existing: ${existingMatchingGroupsCount} + New: 1 <= Max: ${maxAllowedCount})`);
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// The new booking's guest count is smaller than the current limit threshold, so this rule doesn't restrict it.
|
|
200
|
+
logDebug(` Not Applicable: Booking guests (${guestsNumber}) < Limit threshold (${limitSize}). Skipping this check.`);
|
|
201
|
+
}
|
|
202
|
+
} // End of loop through different limitSizes (e.g., checking 6, then 7)
|
|
203
|
+
|
|
204
|
+
// After checking all applicable limits for the timeblock:
|
|
205
|
+
if (isTimeAllowed) {
|
|
206
|
+
logDebug(` ✅ Result for time ${time}: ALLOWED (passed all applicable checks)`);
|
|
207
|
+
filteredBlocks[time] = timeData; // Add it to the results
|
|
208
|
+
} else {
|
|
209
|
+
logDebug(` ❌ Result for time ${time}: BLOCKED (failed at least one limit check)`);
|
|
210
|
+
// Do not add it to filteredBlocks
|
|
211
|
+
}
|
|
212
|
+
} // End of loop through all input timeblocks
|
|
213
|
+
|
|
214
|
+
logDebug(`--- MaxGroupsFilter Finished. Output timeblocks count: ${Object.keys(filteredBlocks).length} ---`);
|
|
215
|
+
return filteredBlocks; // Return the timeblocks that passed all checks
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Export the function for use in other parts of the application
|
|
219
|
+
module.exports = {
|
|
220
|
+
filterTimeblocksByMaxGroups,
|
|
221
|
+
getMealType, // Export getMealType for potential use by other modules
|
|
222
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Archief/Fields/algorithm/stopTimeFilter.js
|
|
2
|
+
|
|
3
|
+
// Assuming moment-timezone is available in the project, as used elsewhere
|
|
4
|
+
const moment = require('moment-timezone');
|
|
5
|
+
|
|
6
|
+
// Import necessary helpers (adjust path if tableHelpers.js is elsewhere)
|
|
7
|
+
// Or redefine shifts, parseTime, getMealTypeByTime here if preferred
|
|
8
|
+
const { shifts, parseTime, getMealTypeByTime } = require('../tableHelpers');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Gets the current time in minutes since midnight in the Brussels timezone.
|
|
12
|
+
* @returns {number} Current time in minutes.
|
|
13
|
+
*/
|
|
14
|
+
function getCurrentTimeInBrusselsMinutes() {
|
|
15
|
+
const now = moment().tz('Europe/Brussels');
|
|
16
|
+
return now.hours() * 60 + now.minutes();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Filters a list of timeblocks based on stop times defined in general settings.
|
|
21
|
+
* This filter only applies if the provided dateStr is the current date.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} timeblocks - An object where keys are time strings ("HH:MM") and values are timeblock data.
|
|
24
|
+
* @param {Object} restaurantData - The main restaurant data object containing general-settings.
|
|
25
|
+
* @param {string} dateStr - The date string ("YYYY-MM-DD") to check against today.
|
|
26
|
+
* @returns {Object} - The filtered timeblocks object.
|
|
27
|
+
*/
|
|
28
|
+
function filterTimeblocksByStopTimes(timeblocks, restaurantData, dateStr) {
|
|
29
|
+
// Determine today's date string in the correct timezone
|
|
30
|
+
const todayStr = moment().tz('Europe/Brussels').format('YYYY-MM-DD');
|
|
31
|
+
|
|
32
|
+
// Only apply the filter if the date being checked is today
|
|
33
|
+
if (dateStr !== todayStr) {
|
|
34
|
+
return timeblocks; // No filtering needed for future dates
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log(`Applying stop time filter for today (${dateStr})`);
|
|
38
|
+
|
|
39
|
+
const settings = restaurantData?.['general-settings'] || {};
|
|
40
|
+
const currentTimeMinutes = getCurrentTimeInBrusselsMinutes();
|
|
41
|
+
|
|
42
|
+
// Get stop times from settings and parse them (handle missing/null values)
|
|
43
|
+
const breakfastStopTimeStr = settings.ontbijtStop || null; // e.g., "10:00"
|
|
44
|
+
const lunchStopTimeStr = settings.lunchStop || null; // e.g., "14:00"
|
|
45
|
+
const dinnerStopTimeStr = settings.dinerStop || null; // e.g., "20:00"
|
|
46
|
+
|
|
47
|
+
const breakfastStopTime = breakfastStopTimeStr ? parseTime(breakfastStopTimeStr) : null;
|
|
48
|
+
const lunchStopTime = lunchStopTimeStr ? parseTime(lunchStopTimeStr) : null;
|
|
49
|
+
const dinnerStopTime = dinnerStopTimeStr ? parseTime(dinnerStopTimeStr) : null;
|
|
50
|
+
|
|
51
|
+
// console.log(`Current time (minutes): ${currentTimeMinutes}`); // Verbose
|
|
52
|
+
// console.log(`Stop times (minutes) - Breakfast: ${breakfastStopTime}, Lunch: ${lunchStopTime}, Dinner: ${dinnerStopTime}`); // Verbose
|
|
53
|
+
|
|
54
|
+
const filteredTimeblocks = {};
|
|
55
|
+
let skippedBreakfast = 0;
|
|
56
|
+
let skippedLunch = 0;
|
|
57
|
+
let skippedDinner = 0;
|
|
58
|
+
|
|
59
|
+
for (const time in timeblocks) {
|
|
60
|
+
if (!timeblocks.hasOwnProperty(time)) continue;
|
|
61
|
+
|
|
62
|
+
const mealType = getMealTypeByTime(time);
|
|
63
|
+
let skip = false;
|
|
64
|
+
|
|
65
|
+
if (mealType === 'breakfast' && breakfastStopTime !== null && !isNaN(breakfastStopTime) && currentTimeMinutes >= breakfastStopTime) {
|
|
66
|
+
skip = true;
|
|
67
|
+
skippedBreakfast++;
|
|
68
|
+
} else if (mealType === 'lunch' && lunchStopTime !== null && !isNaN(lunchStopTime) && currentTimeMinutes >= lunchStopTime) {
|
|
69
|
+
skip = true;
|
|
70
|
+
skippedLunch++;
|
|
71
|
+
} else if (mealType === 'dinner' && dinnerStopTime !== null && !isNaN(dinnerStopTime) && currentTimeMinutes >= dinnerStopTime) {
|
|
72
|
+
skip = true;
|
|
73
|
+
skippedDinner++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!skip) {
|
|
77
|
+
filteredTimeblocks[time] = timeblocks[time];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (skippedBreakfast > 0) console.log(`Stop time filter removed ${skippedBreakfast} breakfast slots.`);
|
|
82
|
+
if (skippedLunch > 0) console.log(`Stop time filter removed ${skippedLunch} lunch slots.`);
|
|
83
|
+
if (skippedDinner > 0) console.log(`Stop time filter removed ${skippedDinner} dinner slots.`);
|
|
84
|
+
|
|
85
|
+
return filteredTimeblocks;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
filterTimeblocksByStopTimes
|
|
90
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// getAvailableTimeblocks.js
|
|
2
|
+
|
|
3
|
+
const { timeblocksAvailable } = require('./processing/timeblocksAvailable');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parses a time string in "HH:MM" format into a Date object on a specific date.
|
|
7
|
+
* @param {string} dateStr - The date string in "YYYY-MM-DD" format.
|
|
8
|
+
* @param {string} timeStr - Time string in "HH:MM" format.
|
|
9
|
+
* @param {string} timeZone - The IANA time zone identifier.
|
|
10
|
+
* @returns {Date} Date object representing the time on the specified date and time zone.
|
|
11
|
+
*/
|
|
12
|
+
function parseDateTimeInTimeZone(dateStr, timeStr, timeZone) {
|
|
13
|
+
const [year, month, day] = dateStr.split('-').map(Number);
|
|
14
|
+
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
15
|
+
|
|
16
|
+
// Create a date object in UTC
|
|
17
|
+
const date = new Date(Date.UTC(year, month - 1, day, hours, minutes));
|
|
18
|
+
// Convert that UTC date/time to the specified time zone
|
|
19
|
+
const dateInTimeZone = new Date(
|
|
20
|
+
date.toLocaleString('en-US', { timeZone: timeZone })
|
|
21
|
+
);
|
|
22
|
+
return dateInTimeZone;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Gets the available time blocks or shifts for a reservation, considering 'uurOpVoorhand' and 'dagenInToekomst'.
|
|
27
|
+
* @param {Object} data - The main data object containing settings and meal information.
|
|
28
|
+
* @param {string} dateStr - The date string in "YYYY-MM-DD" format.
|
|
29
|
+
* @param {Array} reservations - An array of reservation objects.
|
|
30
|
+
* @param {number} guests - The number of guests for the reservation.
|
|
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) {
|
|
34
|
+
// Get 'uurOpVoorhand' from general settings
|
|
35
|
+
let uurOpVoorhand = 4;
|
|
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 CEST
|
|
58
|
+
const now = new Date();
|
|
59
|
+
const currentTimeInTimeZone = new Date(
|
|
60
|
+
now.toLocaleString('en-US', { timeZone: timeZone })
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Calculate the maximum allowed date
|
|
64
|
+
const maxAllowedDate = new Date(currentTimeInTimeZone.getTime());
|
|
65
|
+
maxAllowedDate.setDate(maxAllowedDate.getDate() + dagenInToekomst);
|
|
66
|
+
maxAllowedDate.setHours(23, 59, 59, 999);
|
|
67
|
+
|
|
68
|
+
// Parse the target date in the specified time zone
|
|
69
|
+
const [year, month, day] = dateStr.split('-').map(Number);
|
|
70
|
+
const targetDate = new Date(Date.UTC(year, month - 1, day));
|
|
71
|
+
const targetDateInTimeZone = new Date(
|
|
72
|
+
targetDate.toLocaleString('en-US', { timeZone: timeZone })
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Check if targetDateInTimeZone is within dagenInToekomst
|
|
76
|
+
if (targetDateInTimeZone > maxAllowedDate) {
|
|
77
|
+
// Out of allowed range, return empty object
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check if the target date is today in the specified time zone
|
|
82
|
+
const isToday =
|
|
83
|
+
currentTimeInTimeZone.toDateString() === targetDateInTimeZone.toDateString();
|
|
84
|
+
|
|
85
|
+
// Get available time blocks or shifts
|
|
86
|
+
const availableTimeblocks = timeblocksAvailable(data, dateStr, reservations, guests);
|
|
87
|
+
|
|
88
|
+
// If the date is today and uurOpVoorhand is greater than zero, prune time blocks
|
|
89
|
+
if (isToday && uurOpVoorhand >= 0) {
|
|
90
|
+
const cutoffTime = new Date(currentTimeInTimeZone.getTime());
|
|
91
|
+
cutoffTime.setHours(cutoffTime.getHours() + uurOpVoorhand);
|
|
92
|
+
|
|
93
|
+
for (const [key, value] of Object.entries(availableTimeblocks)) {
|
|
94
|
+
let timeStr = key;
|
|
95
|
+
|
|
96
|
+
const timeBlockDateTime = parseDateTimeInTimeZone(dateStr, timeStr, timeZone);
|
|
97
|
+
|
|
98
|
+
if (timeBlockDateTime < cutoffTime) {
|
|
99
|
+
delete availableTimeblocks[key];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return availableTimeblocks;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
getAvailableTimeblocks,
|
|
109
|
+
};
|
package/grouping.js
ADDED
|
@@ -0,0 +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 };
|
package/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// algorithm/index.js
|
|
2
|
+
|
|
3
|
+
const processing = {
|
|
4
|
+
...require("./processing/dailyGuestCounts"),
|
|
5
|
+
...require("./processing/mealTypeCount"),
|
|
6
|
+
...require("./processing/timeblocksAvailable")
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const reservation_data = {
|
|
10
|
+
...require("./reservation_data/counter")
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const restaurant_data = {
|
|
14
|
+
...require("./restaurant_data/exceptions"),
|
|
15
|
+
...require("./restaurant_data/openinghours")
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const test = {
|
|
19
|
+
...require("./assignTables"),
|
|
20
|
+
...require("./getAvailableTimeblocks"),
|
|
21
|
+
...require("./grouping"),
|
|
22
|
+
...require("./isDateAvailable"),
|
|
23
|
+
...require("./isTimeAvailable"),
|
|
24
|
+
...require("./simulateTableAssignment"),
|
|
25
|
+
...require("./isDateAvailableWithTableCheck"),
|
|
26
|
+
...require("./tableHelpers"),
|
|
27
|
+
...require("./test")
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const filters = {
|
|
31
|
+
...require("./filters/timeFilter"),
|
|
32
|
+
...require("./filters/maxArrivalsFilter"),
|
|
33
|
+
...require("./filters/maxGroupsFilter")
|
|
34
|
+
};
|
|
35
|
+
const tables = {
|
|
36
|
+
...require("./tables/main.js"),
|
|
37
|
+
|
|
38
|
+
};
|
|
39
|
+
// Merge all exports into one
|
|
40
|
+
module.exports = {
|
|
41
|
+
...processing,
|
|
42
|
+
...reservation_data,
|
|
43
|
+
...restaurant_data,
|
|
44
|
+
...test,
|
|
45
|
+
...filters,
|
|
46
|
+
...tables
|
|
47
|
+
};
|