@happychef/algorithm 1.3.2 → 1.4.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.
Files changed (71) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/.github/workflows/ci-cd.yml +80 -80
  3. package/BRANCH_PROTECTION_SETUP.md +167 -167
  4. package/CHANGELOG.md +8 -8
  5. package/README.md +144 -144
  6. package/RESERVERINGEN_GIDS.md +986 -986
  7. package/__tests__/crossMidnight.test.js +63 -0
  8. package/__tests__/crossMidnightTimeblocks.test.js +312 -0
  9. package/__tests__/edgeCases.test.js +271 -0
  10. package/__tests__/filters.test.js +276 -276
  11. package/__tests__/isDateAvailable.test.js +179 -175
  12. package/__tests__/isTimeAvailable.test.js +174 -168
  13. package/__tests__/restaurantData.test.js +422 -422
  14. package/__tests__/tableHelpers.test.js +247 -247
  15. package/assignTables.js +506 -444
  16. package/changes/2025/December/PR2___change.md +14 -14
  17. package/changes/2025/December/PR3_add__change.md +20 -20
  18. package/changes/2025/December/PR4___.md +15 -15
  19. package/changes/2025/December/PR5___.md +15 -15
  20. package/changes/2025/December/PR6__del_.md +17 -17
  21. package/changes/2025/December/PR7_add__change.md +21 -21
  22. package/changes/2026/February/PR15_add__change.md +21 -21
  23. package/changes/2026/February/PR16_add__.md +20 -0
  24. package/changes/2026/February/PR16_add_getDateClosingReasons.md +31 -31
  25. package/changes/2026/January/PR10_add__change.md +21 -21
  26. package/changes/2026/January/PR11_add__change.md +19 -19
  27. package/changes/2026/January/PR12_add__.md +21 -21
  28. package/changes/2026/January/PR13_add__change.md +20 -20
  29. package/changes/2026/January/PR14_add__change.md +19 -19
  30. package/changes/2026/January/PR8_add__change.md +38 -38
  31. package/changes/2026/January/PR9_add__change.md +19 -19
  32. package/dateHelpers.js +31 -0
  33. package/filters/maxArrivalsFilter.js +114 -114
  34. package/filters/maxGroupsFilter.js +221 -221
  35. package/filters/timeFilter.js +89 -89
  36. package/getAvailableTimeblocks.js +158 -158
  37. package/getDateClosingReasons.js +193 -193
  38. package/grouping.js +162 -162
  39. package/index.js +48 -43
  40. package/isDateAvailable.js +80 -80
  41. package/isDateAvailableWithTableCheck.js +172 -172
  42. package/isTimeAvailable.js +26 -26
  43. package/jest.config.js +23 -23
  44. package/package.json +27 -27
  45. package/processing/dailyGuestCounts.js +73 -73
  46. package/processing/mealTypeCount.js +133 -133
  47. package/processing/timeblocksAvailable.js +344 -182
  48. package/reservation_data/counter.js +82 -75
  49. package/restaurant_data/exceptions.js +150 -150
  50. package/restaurant_data/openinghours.js +142 -142
  51. package/simulateTableAssignment.js +833 -726
  52. package/tableHelpers.js +209 -209
  53. package/tables/time/parseTime.js +19 -19
  54. package/tables/time/shifts.js +7 -7
  55. package/tables/utils/calculateDistance.js +13 -13
  56. package/tables/utils/isTableFreeForAllSlots.js +14 -14
  57. package/tables/utils/isTemporaryTableValid.js +39 -39
  58. package/test/test_counter.js +194 -194
  59. package/test/test_dailyCount.js +81 -81
  60. package/test/test_datesAvailable.js +106 -106
  61. package/test/test_exceptions.js +172 -172
  62. package/test/test_isDateAvailable.js +330 -330
  63. package/test/test_mealTypeCount.js +54 -54
  64. package/test/test_timesAvailable.js +88 -88
  65. package/test-detailed-filter.js +100 -100
  66. package/test-lunch-debug.js +110 -110
  67. package/test-max-arrivals-filter.js +79 -79
  68. package/test-meal-stop-fix.js +147 -147
  69. package/test-meal-stop-simple.js +93 -93
  70. package/test-timezone-debug.js +47 -47
  71. package/test.js +336 -336
package/assignTables.js CHANGED
@@ -1,444 +1,506 @@
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 from reservation or settings
303
- let duurReservatie = parseInt(
304
- restaurantSettings["general-settings"]?.duurReservatie?.$numberInt || restaurantSettings["general-settings"]?.duurReservatie || 120
305
- );
306
- // Use reservation specific duration if provided
307
- if (reservation.duration) {
308
- const rawDur = reservation.duration.$numberInt ?? reservation.duration;
309
- const parsedDur = parseInt(rawDur, 10);
310
- if (!isNaN(parsedDur) && parsedDur > 0) {
311
- duurReservatie = parsedDur;
312
- }
313
- }
314
-
315
- const intervalReservatie = parseInt(
316
- restaurantSettings["general-settings"]?.intervalReservatie?.$numberInt || restaurantSettings["general-settings"]?.intervalReservatie || 30
317
- );
318
-
319
- // 7) Compute the requiredSlots for this reservation
320
- const requiredSlots = computeRequiredSlots(time, duurReservatie, intervalReservatie);
321
-
322
- // 8) Get overlapping reservations (same date, same restaurant)
323
- const overlappingReservations = await db.collection('reservations').find({
324
- restaurantId: restaurantId,
325
- date: date
326
- }).toArray();
327
-
328
- // 9) Build tableOccupiedSlots map
329
- let tableOccupiedSlots = {}; // { [tableNumber]: Set([...slots]) }
330
- for (let r of overlappingReservations) {
331
- // No need to skip the current reservation as it's not yet inserted
332
-
333
- // compute that reservation's time slots
334
- // Use reservation specific duration if available, else default
335
- let rDuration = duurReservatie;
336
- if (r.duration) {
337
- // Handle both direct value and MongoDB $numberInt
338
- // Use ?? to correctly handle 0 values
339
- const rawDur = r.duration.$numberInt ?? r.duration;
340
- const parsed = parseInt(rawDur, 10);
341
- if (!isNaN(parsed) && parsed > 0) {
342
- rDuration = parsed;
343
- }
344
- }
345
- const rSlots = computeRequiredSlots(r.time, rDuration, intervalReservatie);
346
-
347
- if (r.tables) {
348
- for (let tn of r.tables) {
349
- if (!tableOccupiedSlots[tn]) {
350
- tableOccupiedSlots[tn] = new Set();
351
- }
352
- rSlots.forEach(slot => {
353
- tableOccupiedSlots[tn].add(slot);
354
- });
355
- }
356
- }
357
- }
358
-
359
- // 10) Helper function to try table assignment from a given set of tables
360
- function tryAssignTables(tables, label) {
361
- // Sort tables
362
- const sortedTables = [...tables].sort((a, b) => {
363
- if (a.maxCapacity !== b.maxCapacity) return a.maxCapacity - b.maxCapacity;
364
- if (a.priority !== b.priority) return a.priority - b.priority;
365
- return a.minCapacity - b.minCapacity;
366
- });
367
-
368
- // Try grouping first
369
- try {
370
- const groupingResult = tryGroupTables({
371
- restaurantSettings,
372
- allTables: sortedTables,
373
- guests,
374
- date,
375
- time,
376
- requiredSlots,
377
- tableOccupiedSlots,
378
- isTemporaryTableValid,
379
- isTableFreeForAllSlots,
380
- });
381
-
382
- if (groupingResult) {
383
- reservation.tables = groupingResult.tables;
384
- reservation.tableIds = groupingResult.tableIds;
385
- reservation._viaGroup = groupingResult.viaGroup;
386
- console.log(`[${label}] [Grouping] via '${reservation._viaGroup}' -> tables ${reservation.tables.join(",")}`);
387
- return true;
388
- }
389
- } catch (err) {
390
- throw err;
391
- }
392
-
393
- // Try single-table assignment
394
- for (let t of sortedTables) {
395
- if (!isTemporaryTableValid(t, date, time)) continue;
396
- if (t.minCapacity <= guests && guests <= t.maxCapacity &&
397
- isTableFreeForAllSlots(t.tableNumber, requiredSlots, tableOccupiedSlots)) {
398
- reservation.tables = [t.tableNumber];
399
- reservation.tableIds = [t.tableId];
400
- console.log(`[${label}] Assigned to table: ${t.tableNumber}`);
401
- return true;
402
- }
403
- }
404
-
405
- // Try multi-table combination
406
- let best = { minDistance: Infinity, tables: [] };
407
- findMultiTableCombination(sortedTables, guests, 0, [], best, requiredSlots, tableOccupiedSlots, date, time);
408
-
409
- if (best.tables.length > 0) {
410
- reservation.tables = best.tables.map(t => t.tableNumber);
411
- reservation.tableIds = best.tables.map(t => t.tableId);
412
- console.log(`[${label}] Assigned to tables: ${reservation.tables.join(', ')}`);
413
- return true;
414
- }
415
-
416
- return false;
417
- }
418
-
419
- // 11) If a linked floor is specified for the Zitplaats, ONLY use that floor (no fallback)
420
- if (preferredFloorTables && preferredFloorTables.length > 0) {
421
- console.log(`[Zitplaats] Trying linked floor only (${preferredFloorTables.length} tables)`);
422
- if (tryAssignTables(preferredFloorTables, 'LinkedFloor')) {
423
- return; // Success on linked floor
424
- }
425
- console.log(`[Zitplaats] No availability on linked floor - not falling back to other floors`);
426
- // Do NOT fall back to all floors when a floor link is specified
427
- } else {
428
- // 12) Try all floors only when no floor link is specified
429
- if (tryAssignTables(allTables, 'AllFloors')) {
430
- return; // Success
431
- }
432
- }
433
-
434
- // 13) No valid table combo found
435
- if (enforceTableAvailability) {
436
- throw new Error(`Désolé, cette table n'est plus disponible. Veuillez choisir un autre jour`);
437
- } else {
438
- console.log('No tables available, but non-enforcing mode => continuing without assignment.');
439
- }
440
- }
441
-
442
- module.exports = {
443
- assignTablesIfPossible
444
- };
1
+ const { tryGroupTables } = require("./grouping");
2
+ const { getNextDateStr, getPrevDateStr } = require("./dateHelpers");
3
+
4
+ /**
5
+ * server side
6
+ * Parses a time string ("HH:MM") into minutes since midnight.
7
+ * Returns NaN if the format is invalid.
8
+ */
9
+ function parseTime(timeStr) {
10
+ if (!timeStr || typeof timeStr !== 'string') return NaN;
11
+ const parts = timeStr.split(':');
12
+ if (parts.length !== 2) return NaN;
13
+ const hours = parseInt(parts[0], 10);
14
+ const minutes = parseInt(parts[1], 10);
15
+ if (isNaN(hours) || isNaN(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
16
+ return NaN;
17
+ }
18
+ return hours * 60 + minutes;
19
+ }
20
+
21
+ const shifts = {
22
+ breakfast: { start: '07:00', end: '11:00' },
23
+ lunch: { start: '11:00', end: '16:00' },
24
+ dinner: { start: '16:00', end: '23:00' },
25
+ };
26
+
27
+ /**
28
+ * Determines the meal type ('breakfast', 'lunch', 'dinner') for a given time string ("HH:MM").
29
+ * Returns null if the time doesn't fall into a defined shift.
30
+ */
31
+ function getMealTypeByTime(timeStr) {
32
+ const time = parseTime(timeStr);
33
+ if (isNaN(time)) return null;
34
+
35
+ for (const [mealType, shift] of Object.entries(shifts)) {
36
+ const start = parseTime(shift.start);
37
+ const end = parseTime(shift.end);
38
+ if (isNaN(start) || isNaN(end)) continue;
39
+
40
+ if (time >= start && time < end) {
41
+ return mealType;
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Checks if a temporary table is valid for a specific reservation date and time.
49
+ * @param {Object} table - The table object with isTemporary, startDate, endDate, application properties.
50
+ * @param {string} reservationDateStr - The date of the reservation ("YYYY-MM-DD").
51
+ * @param {string} reservationTimeStr - The time of the reservation ("HH:MM").
52
+ * @returns {boolean} True if the table is valid, false otherwise.
53
+ */
54
+ function isTemporaryTableValid(table, reservationDateStr, reservationTimeStr) {
55
+ if (!table.isTemporary) {
56
+ return true; // Not temporary, always valid (subject to other checks like availability)
57
+ }
58
+
59
+ // Check date range
60
+ if (!table.startDate || !table.endDate) {
61
+ return false; // Invalid temporary table definition
62
+ }
63
+
64
+ // Basic date string comparison (YYYY-MM-DD format)
65
+ if (reservationDateStr < table.startDate || reservationDateStr > table.endDate) {
66
+ return false;
67
+ }
68
+
69
+ // Check application (meal type/shift)
70
+ const reservationMealType = getMealTypeByTime(reservationTimeStr);
71
+ if (!reservationMealType) {
72
+ return false; // Cannot determine meal type for the reservation
73
+ }
74
+
75
+ if (table.application !== reservationMealType) {
76
+ return false;
77
+ }
78
+
79
+ return true;
80
+ }
81
+
82
+ /**
83
+ * computeRequiredSlots(timeString, durationMinutes, intervalMinutes)
84
+ * Converts a reservation's start time and duration into discrete time slots.
85
+ * e.g., timeString = "12:30", duration = 400 minutes, interval = 50 minutes
86
+ */
87
+ function computeRequiredSlots(timeString, durationMinutes, intervalMinutes) {
88
+ // Validate time format
89
+ const timePattern = /^([01]\d|2[0-3]):([0-5]\d)$/;
90
+ if (!timePattern.test(timeString)) {
91
+ throw new Error("Invalid time format. Expected 'HH:MM'.");
92
+ }
93
+
94
+ const [hour, minute] = timeString.split(":").map(Number);
95
+ const startMinutes = hour * 60 + minute;
96
+ const slotCount = Math.ceil(durationMinutes / intervalMinutes);
97
+ const slots = [];
98
+ for (let i = 0; i < slotCount; i++) {
99
+ slots.push(startMinutes + i * intervalMinutes);
100
+ }
101
+ return slots;
102
+ }
103
+
104
+ /**
105
+ * isTableFreeForAllSlots(tableNumber, requiredSlots, tableOccupiedSlots)
106
+ * Checks if the given tableNumber is free (no overlapping) for all requiredSlots.
107
+ */
108
+ function isTableFreeForAllSlots(tableNumber, requiredSlots, tableOccupiedSlots) {
109
+ const occupiedSlots = tableOccupiedSlots[tableNumber] || new Set();
110
+ // If any slot from requiredSlots is in occupiedSlots, the table is not free
111
+ return !requiredSlots.some(slot => occupiedSlots.has(slot));
112
+ }
113
+
114
+ /**
115
+ * calculateDistance(tableA, tableB)
116
+ * Euclidean distance between two tables (for multi-table distance minimization).
117
+ */
118
+ function calculateDistance(tableA, tableB) {
119
+ const dx = tableA.x - tableB.x;
120
+ const dy = tableA.y - tableB.y;
121
+ return Math.sqrt(dx * dx + dy * dy);
122
+ }
123
+
124
+ function distanceSum(set) {
125
+ let sum = 0;
126
+ for (let i = 0; i < set.length; i++) {
127
+ for (let j = i + 1; j < set.length; j++) {
128
+ sum += calculateDistance(set[i], set[j]);
129
+ }
130
+ }
131
+ return sum;
132
+ }
133
+
134
+ function findMultiTableCombination(
135
+ tables,
136
+ guestsTotal,
137
+ startIndex,
138
+ currentSet,
139
+ best,
140
+ requiredSlots,
141
+ tableOccupiedSlots,
142
+ reservationDate,
143
+ reservationTime,
144
+ suffixMax // optional precomputed array
145
+ ) {
146
+ // Precompute suffix max-capacity once
147
+ if (!suffixMax) {
148
+ suffixMax = new Array(tables.length + 1).fill(0);
149
+ for (let i = tables.length - 1; i >= 0; i--) {
150
+ suffixMax[i] = suffixMax[i + 1] + tables[i].maxCapacity;
151
+ }
152
+ }
153
+
154
+ // Compute current set min/max sums
155
+ let curMin = 0, curMax = 0;
156
+ for (const t of currentSet) {
157
+ curMin += t.minCapacity;
158
+ curMax += t.maxCapacity;
159
+ }
160
+
161
+ // Prune: too many required seats already
162
+ if (curMin > guestsTotal) return;
163
+ // Prune: even with all remaining tables, can't reach guests
164
+ if (curMax + suffixMax[startIndex] < guestsTotal) return;
165
+
166
+ // Feasible set -> evaluate distance and update best
167
+ if (curMin <= guestsTotal && guestsTotal <= curMax) {
168
+ const d = distanceSum(currentSet);
169
+ if (d < best.minDistance) {
170
+ best.minDistance = d;
171
+ best.tables = [...currentSet];
172
+ }
173
+ // Continue searching; a tighter cluster may exist.
174
+ }
175
+
176
+ // Try adding more tables
177
+ for (let i = startIndex; i < tables.length; i++) {
178
+ const tbl = tables[i];
179
+
180
+ if (!isTemporaryTableValid(tbl, reservationDate, reservationTime)) continue;
181
+ if (!isTableFreeForAllSlots(tbl.tableNumber, requiredSlots, tableOccupiedSlots)) continue;
182
+
183
+ currentSet.push(tbl);
184
+ findMultiTableCombination(
185
+ tables,
186
+ guestsTotal,
187
+ i + 1,
188
+ currentSet,
189
+ best,
190
+ requiredSlots,
191
+ tableOccupiedSlots,
192
+ reservationDate,
193
+ reservationTime,
194
+ suffixMax
195
+ );
196
+ currentSet.pop();
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Gets the floor ID linked to a seat place from seatAreaFloorLinks.
202
+ * @param {Object} restaurantSettings - The restaurant settings object.
203
+ * @param {string} seatPlace - The seat place identifier.
204
+ * @returns {string|null} The floor ID or null if not found.
205
+ */
206
+ function getFloorIdForSeatPlace(restaurantSettings, seatPlace) {
207
+ if (!seatPlace || !restaurantSettings) return null;
208
+ const seatAreaFloorLinks = restaurantSettings["general-settings"]?.seatAreaFloorLinks;
209
+ if (!seatAreaFloorLinks || typeof seatAreaFloorLinks !== 'object') return null;
210
+ return seatAreaFloorLinks[seatPlace] || null;
211
+ }
212
+
213
+
214
+ /**
215
+ * assignTablesIfPossible
216
+ * Attempts to assign tables to a reservation based on availability and constraints.
217
+ * Modifies the reservation object by attaching 'tables' and 'tableIds' if successful.
218
+ */
219
+ async function assignTablesIfPossible({
220
+ db,
221
+ reservation,
222
+ enforceTableAvailability
223
+ }) {
224
+ const restaurantId = reservation.restaurantId;
225
+ const date = reservation.date;
226
+ const time = reservation.time;
227
+ const guests = reservation.guests;
228
+ const zitplaats = reservation.zitplaats;
229
+
230
+ // 1) First, fetch restaurant data (which contains the floors information)
231
+ const restaurantSettings = await db.collection('restaurants').findOne({ _id: restaurantId });
232
+ if (!restaurantSettings) {
233
+ if (enforceTableAvailability) {
234
+ throw new Error('Restaurant settings not found.');
235
+ } else {
236
+ return; // Non-enforcing: skip table assignment
237
+ }
238
+ }
239
+
240
+ // 2) Get floors data directly from the restaurant document
241
+ let floorsData = restaurantSettings.floors;
242
+ if (!floorsData || !Array.isArray(floorsData) || floorsData.length === 0) {
243
+ if (enforceTableAvailability) {
244
+ throw new Error('No floors data found in restaurant document.');
245
+ } else {
246
+ return; // Non-enforcing: skip table assignment
247
+ }
248
+ }
249
+
250
+ // 2.5) If zitplaats is specified and has a floor link, try that floor first
251
+ const linkedFloorId = getFloorIdForSeatPlace(restaurantSettings, zitplaats);
252
+ let preferredFloorOnly = null;
253
+ if (linkedFloorId) {
254
+ const linkedFloor = floorsData.find(f => f.id === linkedFloorId);
255
+ if (linkedFloor) {
256
+ preferredFloorOnly = [linkedFloor];
257
+ console.log(`[Zitplaats] Will try floor '${linkedFloorId}' first for zitplaats '${zitplaats}'`);
258
+ }
259
+ }
260
+
261
+ // 3) Helper to collect tables from floors
262
+ function collectTablesFromFloors(floors) {
263
+ const tables = [];
264
+ floors.forEach(floor => {
265
+ if (floor.tables && Array.isArray(floor.tables)) {
266
+ floor.tables.forEach(tbl => {
267
+ if (tbl.objectType === "Tafel") {
268
+ tables.push({
269
+ tableId: tbl.id,
270
+ tableNumber: parseInt(tbl.tableNumber.$numberInt || tbl.tableNumber),
271
+ minCapacity: parseInt(tbl.minCapacity.$numberInt || tbl.minCapacity),
272
+ maxCapacity: parseInt(tbl.maxCapacity.$numberInt || tbl.maxCapacity),
273
+ priority: parseInt(tbl.priority.$numberInt || tbl.priority),
274
+ x: parseInt(tbl.x.$numberInt || tbl.x),
275
+ y: parseInt(tbl.y.$numberInt || tbl.y),
276
+ isTemporary: tbl.isTemporary === true,
277
+ startDate: tbl.startDate || null,
278
+ endDate: tbl.endDate || null,
279
+ application: tbl.application || null
280
+ });
281
+ }
282
+ });
283
+ }
284
+ });
285
+ return tables;
286
+ }
287
+
288
+ // Collect all tables from all floors
289
+ let allTables = collectTablesFromFloors(floorsData);
290
+
291
+ // Collect tables from preferred floor only (if specified)
292
+ let preferredFloorTables = preferredFloorOnly ? collectTablesFromFloors(preferredFloorOnly) : null;
293
+
294
+ // 4) If no tables found
295
+ if (!allTables.length) {
296
+ if (enforceTableAvailability) {
297
+ throw new Error('No tables found for this restaurant.');
298
+ } else {
299
+ return; // Non-enforcing: skip table assignment
300
+ }
301
+ }
302
+
303
+ // 5) Get duration from reservation or settings
304
+ let duurReservatie = parseInt(
305
+ restaurantSettings["general-settings"]?.duurReservatie?.$numberInt || restaurantSettings["general-settings"]?.duurReservatie || 120
306
+ );
307
+ // Use reservation specific duration if provided
308
+ if (reservation.duration) {
309
+ const rawDur = reservation.duration.$numberInt ?? reservation.duration;
310
+ const parsedDur = parseInt(rawDur, 10);
311
+ if (!isNaN(parsedDur) && parsedDur > 0) {
312
+ duurReservatie = parsedDur;
313
+ }
314
+ }
315
+
316
+ const intervalReservatie = parseInt(
317
+ restaurantSettings["general-settings"]?.intervalReservatie?.$numberInt || restaurantSettings["general-settings"]?.intervalReservatie || 30
318
+ );
319
+
320
+ // 7) Compute the requiredSlots for this reservation
321
+ const requiredSlots = computeRequiredSlots(time, duurReservatie, intervalReservatie);
322
+
323
+ // 8) Get overlapping reservations (same date, same restaurant)
324
+ const overlappingReservations = await db.collection('reservations').find({
325
+ restaurantId: restaurantId,
326
+ date: date
327
+ }).toArray();
328
+
329
+ // 9) Build tableOccupiedSlots map
330
+ let tableOccupiedSlots = {}; // { [tableNumber]: Set([...slots]) }
331
+ for (let r of overlappingReservations) {
332
+ // No need to skip the current reservation as it's not yet inserted
333
+
334
+ // compute that reservation's time slots
335
+ // Use reservation specific duration if available, else default
336
+ let rDuration = duurReservatie;
337
+ if (r.duration) {
338
+ // Handle both direct value and MongoDB $numberInt
339
+ // Use ?? to correctly handle 0 values
340
+ const rawDur = r.duration.$numberInt ?? r.duration;
341
+ const parsed = parseInt(rawDur, 10);
342
+ if (!isNaN(parsed) && parsed > 0) {
343
+ rDuration = parsed;
344
+ }
345
+ }
346
+ const rSlots = computeRequiredSlots(r.time, rDuration, intervalReservatie);
347
+
348
+ if (r.tables) {
349
+ for (let tn of r.tables) {
350
+ if (!tableOccupiedSlots[tn]) {
351
+ tableOccupiedSlots[tn] = new Set();
352
+ }
353
+ rSlots.forEach(slot => {
354
+ tableOccupiedSlots[tn].add(slot);
355
+ });
356
+ }
357
+ }
358
+ }
359
+
360
+ // 9b) Cross-date table conflicts: include previous-day reservations that spill past midnight
361
+ const prevDateStr = getPrevDateStr(date);
362
+ const prevDayReservations = await db.collection('reservations').find({
363
+ restaurantId: restaurantId,
364
+ date: prevDateStr
365
+ }).toArray();
366
+
367
+ for (let r of prevDayReservations) {
368
+ let rDuration = duurReservatie;
369
+ if (r.duration) {
370
+ const rawDur = r.duration.$numberInt ?? r.duration;
371
+ const parsed = parseInt(rawDur, 10);
372
+ if (!isNaN(parsed) && parsed > 0) {
373
+ rDuration = parsed;
374
+ }
375
+ }
376
+ const startMinutes = parseTime(r.time);
377
+ if (!isNaN(startMinutes) && startMinutes + rDuration > 1440 && r.tables) {
378
+ // This previous-day reservation crosses midnight into current day
379
+ const rSlots = computeRequiredSlots(r.time, rDuration, intervalReservatie);
380
+ for (let tn of r.tables) {
381
+ if (!tableOccupiedSlots[tn]) {
382
+ tableOccupiedSlots[tn] = new Set();
383
+ }
384
+ // Slots >1440 naturally overlap with current day's late-night slots on the same table
385
+ rSlots.forEach(slot => tableOccupiedSlots[tn].add(slot));
386
+ }
387
+ }
388
+ }
389
+
390
+ // 9c) If new reservation crosses midnight, include next-day reservations offset by +1440
391
+ const crossesMidnight = requiredSlots.some(s => s >= 1440);
392
+ if (crossesMidnight) {
393
+ const nextDateStr = getNextDateStr(date);
394
+ const nextDayReservations = await db.collection('reservations').find({
395
+ restaurantId: restaurantId,
396
+ date: nextDateStr
397
+ }).toArray();
398
+
399
+ for (let r of nextDayReservations) {
400
+ let rDuration = duurReservatie;
401
+ if (r.duration) {
402
+ const rawDur = r.duration.$numberInt ?? r.duration;
403
+ const parsed = parseInt(rawDur, 10);
404
+ if (!isNaN(parsed) && parsed > 0) {
405
+ rDuration = parsed;
406
+ }
407
+ }
408
+ const rSlots = computeRequiredSlots(r.time, rDuration, intervalReservatie);
409
+ if (r.tables) {
410
+ for (let tn of r.tables) {
411
+ if (!tableOccupiedSlots[tn]) {
412
+ tableOccupiedSlots[tn] = new Set();
413
+ }
414
+ // Offset next-day slots by +1440 so they align with our cross-midnight slots
415
+ rSlots.forEach(slot => tableOccupiedSlots[tn].add(slot + 1440));
416
+ }
417
+ }
418
+ }
419
+ }
420
+
421
+ // 10) Helper function to try table assignment from a given set of tables
422
+ function tryAssignTables(tables, label) {
423
+ // Sort tables
424
+ const sortedTables = [...tables].sort((a, b) => {
425
+ if (a.maxCapacity !== b.maxCapacity) return a.maxCapacity - b.maxCapacity;
426
+ if (a.priority !== b.priority) return a.priority - b.priority;
427
+ return a.minCapacity - b.minCapacity;
428
+ });
429
+
430
+ // Try grouping first
431
+ try {
432
+ const groupingResult = tryGroupTables({
433
+ restaurantSettings,
434
+ allTables: sortedTables,
435
+ guests,
436
+ date,
437
+ time,
438
+ requiredSlots,
439
+ tableOccupiedSlots,
440
+ isTemporaryTableValid,
441
+ isTableFreeForAllSlots,
442
+ });
443
+
444
+ if (groupingResult) {
445
+ reservation.tables = groupingResult.tables;
446
+ reservation.tableIds = groupingResult.tableIds;
447
+ reservation._viaGroup = groupingResult.viaGroup;
448
+ console.log(`[${label}] [Grouping] via '${reservation._viaGroup}' -> tables ${reservation.tables.join(",")}`);
449
+ return true;
450
+ }
451
+ } catch (err) {
452
+ throw err;
453
+ }
454
+
455
+ // Try single-table assignment
456
+ for (let t of sortedTables) {
457
+ if (!isTemporaryTableValid(t, date, time)) continue;
458
+ if (t.minCapacity <= guests && guests <= t.maxCapacity &&
459
+ isTableFreeForAllSlots(t.tableNumber, requiredSlots, tableOccupiedSlots)) {
460
+ reservation.tables = [t.tableNumber];
461
+ reservation.tableIds = [t.tableId];
462
+ console.log(`[${label}] Assigned to table: ${t.tableNumber}`);
463
+ return true;
464
+ }
465
+ }
466
+
467
+ // Try multi-table combination
468
+ let best = { minDistance: Infinity, tables: [] };
469
+ findMultiTableCombination(sortedTables, guests, 0, [], best, requiredSlots, tableOccupiedSlots, date, time);
470
+
471
+ if (best.tables.length > 0) {
472
+ reservation.tables = best.tables.map(t => t.tableNumber);
473
+ reservation.tableIds = best.tables.map(t => t.tableId);
474
+ console.log(`[${label}] Assigned to tables: ${reservation.tables.join(', ')}`);
475
+ return true;
476
+ }
477
+
478
+ return false;
479
+ }
480
+
481
+ // 11) If a linked floor is specified for the Zitplaats, ONLY use that floor (no fallback)
482
+ if (preferredFloorTables && preferredFloorTables.length > 0) {
483
+ console.log(`[Zitplaats] Trying linked floor only (${preferredFloorTables.length} tables)`);
484
+ if (tryAssignTables(preferredFloorTables, 'LinkedFloor')) {
485
+ return; // Success on linked floor
486
+ }
487
+ console.log(`[Zitplaats] No availability on linked floor - not falling back to other floors`);
488
+ // Do NOT fall back to all floors when a floor link is specified
489
+ } else {
490
+ // 12) Try all floors only when no floor link is specified
491
+ if (tryAssignTables(allTables, 'AllFloors')) {
492
+ return; // Success
493
+ }
494
+ }
495
+
496
+ // 13) No valid table combo found
497
+ if (enforceTableAvailability) {
498
+ throw new Error(`Désolé, cette table n'est plus disponible. Veuillez choisir un autre jour`);
499
+ } else {
500
+ console.log('No tables available, but non-enforcing mode => continuing without assignment.');
501
+ }
502
+ }
503
+
504
+ module.exports = {
505
+ assignTablesIfPossible
506
+ };