@happychef/algorithm 1.2.10 → 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 +133 -2
- package/BRANCH_PROTECTION_SETUP.md +167 -0
- package/CHANGELOG.md +8 -8
- package/RESERVERINGEN_GIDS.md +986 -986
- package/assignTables.js +424 -398
- 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 +16 -0
- package/changes/2025/December/PR5___.md +16 -0
- package/changes/2025/December/PR6__del_.md +18 -0
- package/changes/2025/December/PR7_add__change.md +22 -0
- 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/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-meal-stop-fix.js +147 -147
- package/test-meal-stop-simple.js +93 -93
- package/test.js +336 -336
package/assignTables.js
CHANGED
|
@@ -1,398 +1,424 @@
|
|
|
1
|
-
const { tryGroupTables } = require("./grouping");
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* server side
|
|
5
|
-
* Parses a time string ("HH:MM") into minutes since midnight.
|
|
6
|
-
* Returns NaN if the format is invalid.
|
|
7
|
-
*/
|
|
8
|
-
function parseTime(timeStr) {
|
|
9
|
-
if (!timeStr || typeof timeStr !== 'string') return NaN;
|
|
10
|
-
const parts = timeStr.split(':');
|
|
11
|
-
if (parts.length !== 2) return NaN;
|
|
12
|
-
const hours = parseInt(parts[0], 10);
|
|
13
|
-
const minutes = parseInt(parts[1], 10);
|
|
14
|
-
if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
|
15
|
-
return NaN;
|
|
16
|
-
}
|
|
17
|
-
return hours * 60 + minutes;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const shifts = {
|
|
21
|
-
breakfast: { start: '07:00', end: '11:00' },
|
|
22
|
-
lunch: { start: '11:00', end: '16:00' },
|
|
23
|
-
dinner: { start: '16:00', end: '23:00' },
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Determines the meal type ('breakfast', 'lunch', 'dinner') for a given time string ("HH:MM").
|
|
28
|
-
* Returns null if the time doesn't fall into a defined shift.
|
|
29
|
-
*/
|
|
30
|
-
function getMealTypeByTime(timeStr) {
|
|
31
|
-
const time = parseTime(timeStr);
|
|
32
|
-
if (isNaN(time)) return null;
|
|
33
|
-
|
|
34
|
-
for (const [mealType, shift] of Object.entries(shifts)) {
|
|
35
|
-
const start = parseTime(shift.start);
|
|
36
|
-
const end = parseTime(shift.end);
|
|
37
|
-
if (isNaN(start) || isNaN(end)) continue;
|
|
38
|
-
|
|
39
|
-
if (time >= start && time < end) {
|
|
40
|
-
return mealType;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Checks if a temporary table is valid for a specific reservation date and time.
|
|
48
|
-
* @param {Object} table - The table object with isTemporary, startDate, endDate, application properties.
|
|
49
|
-
* @param {string} reservationDateStr - The date of the reservation ("YYYY-MM-DD").
|
|
50
|
-
* @param {string} reservationTimeStr - The time of the reservation ("HH:MM").
|
|
51
|
-
* @returns {boolean} True if the table is valid, false otherwise.
|
|
52
|
-
*/
|
|
53
|
-
function isTemporaryTableValid(table, reservationDateStr, reservationTimeStr) {
|
|
54
|
-
if (!table.isTemporary) {
|
|
55
|
-
return true; // Not temporary, always valid (subject to other checks like availability)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Check date range
|
|
59
|
-
if (!table.startDate || !table.endDate) {
|
|
60
|
-
return false; // Invalid temporary table definition
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Basic date string comparison (YYYY-MM-DD format)
|
|
64
|
-
if (reservationDateStr < table.startDate || reservationDateStr > table.endDate) {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Check application (meal type/shift)
|
|
69
|
-
const reservationMealType = getMealTypeByTime(reservationTimeStr);
|
|
70
|
-
if (!reservationMealType) {
|
|
71
|
-
return false; // Cannot determine meal type for the reservation
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (table.application !== reservationMealType) {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* computeRequiredSlots(timeString, durationMinutes, intervalMinutes)
|
|
83
|
-
* Converts a reservation's start time and duration into discrete time slots.
|
|
84
|
-
* e.g., timeString = "12:30", duration = 400 minutes, interval = 50 minutes
|
|
85
|
-
*/
|
|
86
|
-
function computeRequiredSlots(timeString, durationMinutes, intervalMinutes) {
|
|
87
|
-
// Validate time format
|
|
88
|
-
const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
89
|
-
if (!timePattern.test(timeString)) {
|
|
90
|
-
throw new Error("Invalid time format. Expected 'HH:MM'.");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const [hour, minute] = timeString.split(":").map(Number);
|
|
94
|
-
const startMinutes = hour * 60 + minute;
|
|
95
|
-
const slotCount = Math.ceil(durationMinutes / intervalMinutes);
|
|
96
|
-
const slots = [];
|
|
97
|
-
for (let i = 0; i < slotCount; i++) {
|
|
98
|
-
slots.push(startMinutes + i * intervalMinutes);
|
|
99
|
-
}
|
|
100
|
-
return slots;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* isTableFreeForAllSlots(tableNumber, requiredSlots, tableOccupiedSlots)
|
|
105
|
-
* Checks if the given tableNumber is free (no overlapping) for all requiredSlots.
|
|
106
|
-
*/
|
|
107
|
-
function isTableFreeForAllSlots(tableNumber, requiredSlots, tableOccupiedSlots) {
|
|
108
|
-
const occupiedSlots = tableOccupiedSlots[tableNumber] || new Set();
|
|
109
|
-
// If any slot from requiredSlots is in occupiedSlots, the table is not free
|
|
110
|
-
return !requiredSlots.some(slot => occupiedSlots.has(slot));
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* calculateDistance(tableA, tableB)
|
|
115
|
-
* Euclidean distance between two tables (for multi-table distance minimization).
|
|
116
|
-
*/
|
|
117
|
-
function calculateDistance(tableA, tableB) {
|
|
118
|
-
const dx = tableA.x - tableB.x;
|
|
119
|
-
const dy = tableA.y - tableB.y;
|
|
120
|
-
return Math.sqrt(dx * dx + dy * dy);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function distanceSum(set) {
|
|
124
|
-
let sum = 0;
|
|
125
|
-
for (let i = 0; i < set.length; i++) {
|
|
126
|
-
for (let j = i + 1; j < set.length; j++) {
|
|
127
|
-
sum += calculateDistance(set[i], set[j]);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return sum;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function findMultiTableCombination(
|
|
134
|
-
tables,
|
|
135
|
-
guestsTotal,
|
|
136
|
-
startIndex,
|
|
137
|
-
currentSet,
|
|
138
|
-
best,
|
|
139
|
-
requiredSlots,
|
|
140
|
-
tableOccupiedSlots,
|
|
141
|
-
reservationDate,
|
|
142
|
-
reservationTime,
|
|
143
|
-
suffixMax // optional precomputed array
|
|
144
|
-
) {
|
|
145
|
-
// Precompute suffix max-capacity once
|
|
146
|
-
if (!suffixMax) {
|
|
147
|
-
suffixMax = new Array(tables.length + 1).fill(0);
|
|
148
|
-
for (let i = tables.length - 1; i >= 0; i--) {
|
|
149
|
-
suffixMax[i] = suffixMax[i + 1] + tables[i].maxCapacity;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Compute current set min/max sums
|
|
154
|
-
let curMin = 0, curMax = 0;
|
|
155
|
-
for (const t of currentSet) {
|
|
156
|
-
curMin += t.minCapacity;
|
|
157
|
-
curMax += t.maxCapacity;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Prune: too many required seats already
|
|
161
|
-
if (curMin > guestsTotal) return;
|
|
162
|
-
// Prune: even with all remaining tables, can't reach guests
|
|
163
|
-
if (curMax + suffixMax[startIndex] < guestsTotal) return;
|
|
164
|
-
|
|
165
|
-
// Feasible set -> evaluate distance and update best
|
|
166
|
-
if (curMin <= guestsTotal && guestsTotal <= curMax) {
|
|
167
|
-
const d = distanceSum(currentSet);
|
|
168
|
-
if (d < best.minDistance) {
|
|
169
|
-
best.minDistance = d;
|
|
170
|
-
best.tables = [...currentSet];
|
|
171
|
-
}
|
|
172
|
-
// Continue searching; a tighter cluster may exist.
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Try adding more tables
|
|
176
|
-
for (let i = startIndex; i < tables.length; i++) {
|
|
177
|
-
const tbl = tables[i];
|
|
178
|
-
|
|
179
|
-
if (!isTemporaryTableValid(tbl, reservationDate, reservationTime)) continue;
|
|
180
|
-
if (!isTableFreeForAllSlots(tbl.tableNumber, requiredSlots, tableOccupiedSlots)) continue;
|
|
181
|
-
|
|
182
|
-
currentSet.push(tbl);
|
|
183
|
-
findMultiTableCombination(
|
|
184
|
-
tables,
|
|
185
|
-
guestsTotal,
|
|
186
|
-
i + 1,
|
|
187
|
-
currentSet,
|
|
188
|
-
best,
|
|
189
|
-
requiredSlots,
|
|
190
|
-
tableOccupiedSlots,
|
|
191
|
-
reservationDate,
|
|
192
|
-
reservationTime,
|
|
193
|
-
suffixMax
|
|
194
|
-
);
|
|
195
|
-
currentSet.pop();
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
1
|
+
const { tryGroupTables } = require("./grouping");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* server side
|
|
5
|
+
* Parses a time string ("HH:MM") into minutes since midnight.
|
|
6
|
+
* Returns NaN if the format is invalid.
|
|
7
|
+
*/
|
|
8
|
+
function parseTime(timeStr) {
|
|
9
|
+
if (!timeStr || typeof timeStr !== 'string') return NaN;
|
|
10
|
+
const parts = timeStr.split(':');
|
|
11
|
+
if (parts.length !== 2) return NaN;
|
|
12
|
+
const hours = parseInt(parts[0], 10);
|
|
13
|
+
const minutes = parseInt(parts[1], 10);
|
|
14
|
+
if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
|
15
|
+
return NaN;
|
|
16
|
+
}
|
|
17
|
+
return hours * 60 + minutes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const shifts = {
|
|
21
|
+
breakfast: { start: '07:00', end: '11:00' },
|
|
22
|
+
lunch: { start: '11:00', end: '16:00' },
|
|
23
|
+
dinner: { start: '16:00', end: '23:00' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Determines the meal type ('breakfast', 'lunch', 'dinner') for a given time string ("HH:MM").
|
|
28
|
+
* Returns null if the time doesn't fall into a defined shift.
|
|
29
|
+
*/
|
|
30
|
+
function getMealTypeByTime(timeStr) {
|
|
31
|
+
const time = parseTime(timeStr);
|
|
32
|
+
if (isNaN(time)) return null;
|
|
33
|
+
|
|
34
|
+
for (const [mealType, shift] of Object.entries(shifts)) {
|
|
35
|
+
const start = parseTime(shift.start);
|
|
36
|
+
const end = parseTime(shift.end);
|
|
37
|
+
if (isNaN(start) || isNaN(end)) continue;
|
|
38
|
+
|
|
39
|
+
if (time >= start && time < end) {
|
|
40
|
+
return mealType;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Checks if a temporary table is valid for a specific reservation date and time.
|
|
48
|
+
* @param {Object} table - The table object with isTemporary, startDate, endDate, application properties.
|
|
49
|
+
* @param {string} reservationDateStr - The date of the reservation ("YYYY-MM-DD").
|
|
50
|
+
* @param {string} reservationTimeStr - The time of the reservation ("HH:MM").
|
|
51
|
+
* @returns {boolean} True if the table is valid, false otherwise.
|
|
52
|
+
*/
|
|
53
|
+
function isTemporaryTableValid(table, reservationDateStr, reservationTimeStr) {
|
|
54
|
+
if (!table.isTemporary) {
|
|
55
|
+
return true; // Not temporary, always valid (subject to other checks like availability)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check date range
|
|
59
|
+
if (!table.startDate || !table.endDate) {
|
|
60
|
+
return false; // Invalid temporary table definition
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Basic date string comparison (YYYY-MM-DD format)
|
|
64
|
+
if (reservationDateStr < table.startDate || reservationDateStr > table.endDate) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check application (meal type/shift)
|
|
69
|
+
const reservationMealType = getMealTypeByTime(reservationTimeStr);
|
|
70
|
+
if (!reservationMealType) {
|
|
71
|
+
return false; // Cannot determine meal type for the reservation
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (table.application !== reservationMealType) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* computeRequiredSlots(timeString, durationMinutes, intervalMinutes)
|
|
83
|
+
* Converts a reservation's start time and duration into discrete time slots.
|
|
84
|
+
* e.g., timeString = "12:30", duration = 400 minutes, interval = 50 minutes
|
|
85
|
+
*/
|
|
86
|
+
function computeRequiredSlots(timeString, durationMinutes, intervalMinutes) {
|
|
87
|
+
// Validate time format
|
|
88
|
+
const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
89
|
+
if (!timePattern.test(timeString)) {
|
|
90
|
+
throw new Error("Invalid time format. Expected 'HH:MM'.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const [hour, minute] = timeString.split(":").map(Number);
|
|
94
|
+
const startMinutes = hour * 60 + minute;
|
|
95
|
+
const slotCount = Math.ceil(durationMinutes / intervalMinutes);
|
|
96
|
+
const slots = [];
|
|
97
|
+
for (let i = 0; i < slotCount; i++) {
|
|
98
|
+
slots.push(startMinutes + i * intervalMinutes);
|
|
99
|
+
}
|
|
100
|
+
return slots;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* isTableFreeForAllSlots(tableNumber, requiredSlots, tableOccupiedSlots)
|
|
105
|
+
* Checks if the given tableNumber is free (no overlapping) for all requiredSlots.
|
|
106
|
+
*/
|
|
107
|
+
function isTableFreeForAllSlots(tableNumber, requiredSlots, tableOccupiedSlots) {
|
|
108
|
+
const occupiedSlots = tableOccupiedSlots[tableNumber] || new Set();
|
|
109
|
+
// If any slot from requiredSlots is in occupiedSlots, the table is not free
|
|
110
|
+
return !requiredSlots.some(slot => occupiedSlots.has(slot));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* calculateDistance(tableA, tableB)
|
|
115
|
+
* Euclidean distance between two tables (for multi-table distance minimization).
|
|
116
|
+
*/
|
|
117
|
+
function calculateDistance(tableA, tableB) {
|
|
118
|
+
const dx = tableA.x - tableB.x;
|
|
119
|
+
const dy = tableA.y - tableB.y;
|
|
120
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function distanceSum(set) {
|
|
124
|
+
let sum = 0;
|
|
125
|
+
for (let i = 0; i < set.length; i++) {
|
|
126
|
+
for (let j = i + 1; j < set.length; j++) {
|
|
127
|
+
sum += calculateDistance(set[i], set[j]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return sum;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findMultiTableCombination(
|
|
134
|
+
tables,
|
|
135
|
+
guestsTotal,
|
|
136
|
+
startIndex,
|
|
137
|
+
currentSet,
|
|
138
|
+
best,
|
|
139
|
+
requiredSlots,
|
|
140
|
+
tableOccupiedSlots,
|
|
141
|
+
reservationDate,
|
|
142
|
+
reservationTime,
|
|
143
|
+
suffixMax // optional precomputed array
|
|
144
|
+
) {
|
|
145
|
+
// Precompute suffix max-capacity once
|
|
146
|
+
if (!suffixMax) {
|
|
147
|
+
suffixMax = new Array(tables.length + 1).fill(0);
|
|
148
|
+
for (let i = tables.length - 1; i >= 0; i--) {
|
|
149
|
+
suffixMax[i] = suffixMax[i + 1] + tables[i].maxCapacity;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Compute current set min/max sums
|
|
154
|
+
let curMin = 0, curMax = 0;
|
|
155
|
+
for (const t of currentSet) {
|
|
156
|
+
curMin += t.minCapacity;
|
|
157
|
+
curMax += t.maxCapacity;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Prune: too many required seats already
|
|
161
|
+
if (curMin > guestsTotal) return;
|
|
162
|
+
// Prune: even with all remaining tables, can't reach guests
|
|
163
|
+
if (curMax + suffixMax[startIndex] < guestsTotal) return;
|
|
164
|
+
|
|
165
|
+
// Feasible set -> evaluate distance and update best
|
|
166
|
+
if (curMin <= guestsTotal && guestsTotal <= curMax) {
|
|
167
|
+
const d = distanceSum(currentSet);
|
|
168
|
+
if (d < best.minDistance) {
|
|
169
|
+
best.minDistance = d;
|
|
170
|
+
best.tables = [...currentSet];
|
|
171
|
+
}
|
|
172
|
+
// Continue searching; a tighter cluster may exist.
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Try adding more tables
|
|
176
|
+
for (let i = startIndex; i < tables.length; i++) {
|
|
177
|
+
const tbl = tables[i];
|
|
178
|
+
|
|
179
|
+
if (!isTemporaryTableValid(tbl, reservationDate, reservationTime)) continue;
|
|
180
|
+
if (!isTableFreeForAllSlots(tbl.tableNumber, requiredSlots, tableOccupiedSlots)) continue;
|
|
181
|
+
|
|
182
|
+
currentSet.push(tbl);
|
|
183
|
+
findMultiTableCombination(
|
|
184
|
+
tables,
|
|
185
|
+
guestsTotal,
|
|
186
|
+
i + 1,
|
|
187
|
+
currentSet,
|
|
188
|
+
best,
|
|
189
|
+
requiredSlots,
|
|
190
|
+
tableOccupiedSlots,
|
|
191
|
+
reservationDate,
|
|
192
|
+
reservationTime,
|
|
193
|
+
suffixMax
|
|
194
|
+
);
|
|
195
|
+
currentSet.pop();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Gets the floor ID linked to a seat place from seatAreaFloorLinks.
|
|
201
|
+
* @param {Object} restaurantSettings - The restaurant settings object.
|
|
202
|
+
* @param {string} seatPlace - The seat place identifier.
|
|
203
|
+
* @returns {string|null} The floor ID or null if not found.
|
|
204
|
+
*/
|
|
205
|
+
function getFloorIdForSeatPlace(restaurantSettings, seatPlace) {
|
|
206
|
+
if (!seatPlace || !restaurantSettings) return null;
|
|
207
|
+
const seatAreaFloorLinks = restaurantSettings["general-settings"]?.seatAreaFloorLinks;
|
|
208
|
+
if (!seatAreaFloorLinks || typeof seatAreaFloorLinks !== 'object') return null;
|
|
209
|
+
return seatAreaFloorLinks[seatPlace] || null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* assignTablesIfPossible
|
|
215
|
+
* Attempts to assign tables to a reservation based on availability and constraints.
|
|
216
|
+
* Modifies the reservation object by attaching 'tables' and 'tableIds' if successful.
|
|
217
|
+
*/
|
|
218
|
+
async function assignTablesIfPossible({
|
|
219
|
+
db,
|
|
220
|
+
reservation,
|
|
221
|
+
enforceTableAvailability
|
|
222
|
+
}) {
|
|
223
|
+
const restaurantId = reservation.restaurantId;
|
|
224
|
+
const date = reservation.date;
|
|
225
|
+
const time = reservation.time;
|
|
226
|
+
const guests = reservation.guests;
|
|
227
|
+
const zitplaats = reservation.zitplaats;
|
|
228
|
+
|
|
229
|
+
// 1) First, fetch restaurant data (which contains the floors information)
|
|
230
|
+
const restaurantSettings = await db.collection('restaurants').findOne({ _id: restaurantId });
|
|
231
|
+
if (!restaurantSettings) {
|
|
232
|
+
if (enforceTableAvailability) {
|
|
233
|
+
throw new Error('Restaurant settings not found.');
|
|
234
|
+
} else {
|
|
235
|
+
return; // Non-enforcing: skip table assignment
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 2) Get floors data directly from the restaurant document
|
|
240
|
+
let floorsData = restaurantSettings.floors;
|
|
241
|
+
if (!floorsData || !Array.isArray(floorsData) || floorsData.length === 0) {
|
|
242
|
+
if (enforceTableAvailability) {
|
|
243
|
+
throw new Error('No floors data found in restaurant document.');
|
|
244
|
+
} else {
|
|
245
|
+
return; // Non-enforcing: skip table assignment
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 2.5) If zitplaats is specified and has a floor link, try that floor first
|
|
250
|
+
const linkedFloorId = getFloorIdForSeatPlace(restaurantSettings, zitplaats);
|
|
251
|
+
let preferredFloorOnly = null;
|
|
252
|
+
if (linkedFloorId) {
|
|
253
|
+
const linkedFloor = floorsData.find(f => f.id === linkedFloorId);
|
|
254
|
+
if (linkedFloor) {
|
|
255
|
+
preferredFloorOnly = [linkedFloor];
|
|
256
|
+
console.log(`[Zitplaats] Will try floor '${linkedFloorId}' first for zitplaats '${zitplaats}'`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 3) Helper to collect tables from floors
|
|
261
|
+
function collectTablesFromFloors(floors) {
|
|
262
|
+
const tables = [];
|
|
263
|
+
floors.forEach(floor => {
|
|
264
|
+
if (floor.tables && Array.isArray(floor.tables)) {
|
|
265
|
+
floor.tables.forEach(tbl => {
|
|
266
|
+
if (tbl.objectType === "Tafel") {
|
|
267
|
+
tables.push({
|
|
268
|
+
tableId: tbl.id,
|
|
269
|
+
tableNumber: parseInt(tbl.tableNumber.$numberInt || tbl.tableNumber),
|
|
270
|
+
minCapacity: parseInt(tbl.minCapacity.$numberInt || tbl.minCapacity),
|
|
271
|
+
maxCapacity: parseInt(tbl.maxCapacity.$numberInt || tbl.maxCapacity),
|
|
272
|
+
priority: parseInt(tbl.priority.$numberInt || tbl.priority),
|
|
273
|
+
x: parseInt(tbl.x.$numberInt || tbl.x),
|
|
274
|
+
y: parseInt(tbl.y.$numberInt || tbl.y),
|
|
275
|
+
isTemporary: tbl.isTemporary === true,
|
|
276
|
+
startDate: tbl.startDate || null,
|
|
277
|
+
endDate: tbl.endDate || null,
|
|
278
|
+
application: tbl.application || null
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
return tables;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Collect all tables from all floors
|
|
288
|
+
let allTables = collectTablesFromFloors(floorsData);
|
|
289
|
+
|
|
290
|
+
// Collect tables from preferred floor only (if specified)
|
|
291
|
+
let preferredFloorTables = preferredFloorOnly ? collectTablesFromFloors(preferredFloorOnly) : null;
|
|
292
|
+
|
|
293
|
+
// 4) If no tables found
|
|
294
|
+
if (!allTables.length) {
|
|
295
|
+
if (enforceTableAvailability) {
|
|
296
|
+
throw new Error('No tables found for this restaurant.');
|
|
297
|
+
} else {
|
|
298
|
+
return; // Non-enforcing: skip table assignment
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 5) Get duration and interval settings
|
|
303
|
+
const duurReservatie = parseInt(
|
|
304
|
+
restaurantSettings["general-settings"]?.duurReservatie?.$numberInt || restaurantSettings["general-settings"]?.duurReservatie || 120
|
|
305
|
+
);
|
|
306
|
+
const intervalReservatie = parseInt(
|
|
307
|
+
restaurantSettings["general-settings"]?.intervalReservatie?.$numberInt || restaurantSettings["general-settings"]?.intervalReservatie || 30
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// 7) Compute the requiredSlots for this reservation
|
|
311
|
+
const requiredSlots = computeRequiredSlots(time, duurReservatie, intervalReservatie);
|
|
312
|
+
|
|
313
|
+
// 8) Get overlapping reservations (same date, same restaurant)
|
|
314
|
+
const overlappingReservations = await db.collection('reservations').find({
|
|
315
|
+
restaurantId: restaurantId,
|
|
316
|
+
date: date
|
|
317
|
+
}).toArray();
|
|
318
|
+
|
|
319
|
+
// 9) Build tableOccupiedSlots map
|
|
320
|
+
let tableOccupiedSlots = {}; // { [tableNumber]: Set([...slots]) }
|
|
321
|
+
for (let r of overlappingReservations) {
|
|
322
|
+
// No need to skip the current reservation as it's not yet inserted
|
|
323
|
+
|
|
324
|
+
// compute that reservation's time slots
|
|
325
|
+
const rDuration = duurReservatie; // assuming same duration
|
|
326
|
+
const rSlots = computeRequiredSlots(r.time, rDuration, intervalReservatie);
|
|
327
|
+
|
|
328
|
+
if (r.tables) {
|
|
329
|
+
for (let tn of r.tables) {
|
|
330
|
+
if (!tableOccupiedSlots[tn]) {
|
|
331
|
+
tableOccupiedSlots[tn] = new Set();
|
|
332
|
+
}
|
|
333
|
+
rSlots.forEach(slot => {
|
|
334
|
+
tableOccupiedSlots[tn].add(slot);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 10) Helper function to try table assignment from a given set of tables
|
|
341
|
+
function tryAssignTables(tables, label) {
|
|
342
|
+
// Sort tables
|
|
343
|
+
const sortedTables = [...tables].sort((a, b) => {
|
|
344
|
+
if (a.maxCapacity !== b.maxCapacity) return a.maxCapacity - b.maxCapacity;
|
|
345
|
+
if (a.priority !== b.priority) return a.priority - b.priority;
|
|
346
|
+
return a.minCapacity - b.minCapacity;
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Try grouping first
|
|
350
|
+
try {
|
|
351
|
+
const groupingResult = tryGroupTables({
|
|
352
|
+
restaurantSettings,
|
|
353
|
+
allTables: sortedTables,
|
|
354
|
+
guests,
|
|
355
|
+
date,
|
|
356
|
+
time,
|
|
357
|
+
requiredSlots,
|
|
358
|
+
tableOccupiedSlots,
|
|
359
|
+
isTemporaryTableValid,
|
|
360
|
+
isTableFreeForAllSlots,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
if (groupingResult) {
|
|
364
|
+
reservation.tables = groupingResult.tables;
|
|
365
|
+
reservation.tableIds = groupingResult.tableIds;
|
|
366
|
+
reservation._viaGroup = groupingResult.viaGroup;
|
|
367
|
+
console.log(`[${label}] [Grouping] via '${reservation._viaGroup}' -> tables ${reservation.tables.join(",")}`);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
} catch (err) {
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Try single-table assignment
|
|
375
|
+
for (let t of sortedTables) {
|
|
376
|
+
if (!isTemporaryTableValid(t, date, time)) continue;
|
|
377
|
+
if (t.minCapacity <= guests && guests <= t.maxCapacity &&
|
|
378
|
+
isTableFreeForAllSlots(t.tableNumber, requiredSlots, tableOccupiedSlots)) {
|
|
379
|
+
reservation.tables = [t.tableNumber];
|
|
380
|
+
reservation.tableIds = [t.tableId];
|
|
381
|
+
console.log(`[${label}] Assigned to table: ${t.tableNumber}`);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Try multi-table combination
|
|
387
|
+
let best = { minDistance: Infinity, tables: [] };
|
|
388
|
+
findMultiTableCombination(sortedTables, guests, 0, [], best, requiredSlots, tableOccupiedSlots, date, time);
|
|
389
|
+
|
|
390
|
+
if (best.tables.length > 0) {
|
|
391
|
+
reservation.tables = best.tables.map(t => t.tableNumber);
|
|
392
|
+
reservation.tableIds = best.tables.map(t => t.tableId);
|
|
393
|
+
console.log(`[${label}] Assigned to tables: ${reservation.tables.join(', ')}`);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 11) Try preferred floor first (if specified), then fall back to all floors
|
|
401
|
+
if (preferredFloorTables && preferredFloorTables.length > 0) {
|
|
402
|
+
console.log(`[Zitplaats] Trying preferred floor first (${preferredFloorTables.length} tables)`);
|
|
403
|
+
if (tryAssignTables(preferredFloorTables, 'PreferredFloor')) {
|
|
404
|
+
return; // Success on preferred floor
|
|
405
|
+
}
|
|
406
|
+
console.log(`[Zitplaats] No availability on preferred floor, falling back to all floors`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 12) Try all floors
|
|
410
|
+
if (tryAssignTables(allTables, 'AllFloors')) {
|
|
411
|
+
return; // Success
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 13) No valid table combo found
|
|
415
|
+
if (enforceTableAvailability) {
|
|
416
|
+
throw new Error('Unable to find enough tables for this reservation with enforcement on.');
|
|
417
|
+
} else {
|
|
418
|
+
console.log('No tables available, but non-enforcing mode => continuing without assignment.');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
module.exports = {
|
|
423
|
+
assignTablesIfPossible
|
|
424
|
+
};
|