@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.
Files changed (46) hide show
  1. package/.github/workflows/ci-cd.yml +133 -2
  2. package/BRANCH_PROTECTION_SETUP.md +167 -0
  3. package/CHANGELOG.md +8 -8
  4. package/RESERVERINGEN_GIDS.md +986 -986
  5. package/assignTables.js +424 -398
  6. package/changes/2025/December/PR2___change.md +14 -14
  7. package/changes/2025/December/PR3_add__change.md +20 -20
  8. package/changes/2025/December/PR4___.md +16 -0
  9. package/changes/2025/December/PR5___.md +16 -0
  10. package/changes/2025/December/PR6__del_.md +18 -0
  11. package/changes/2025/December/PR7_add__change.md +22 -0
  12. package/changes/2026/January/PR8_add__change.md +39 -0
  13. package/changes/2026/January/PR9_add__change.md +20 -0
  14. package/filters/maxArrivalsFilter.js +114 -114
  15. package/filters/maxGroupsFilter.js +221 -221
  16. package/filters/timeFilter.js +89 -89
  17. package/getAvailableTimeblocks.js +158 -158
  18. package/grouping.js +162 -162
  19. package/index.js +42 -42
  20. package/isDateAvailable.js +80 -80
  21. package/isDateAvailableWithTableCheck.js +171 -171
  22. package/isTimeAvailable.js +25 -25
  23. package/package.json +27 -27
  24. package/processing/dailyGuestCounts.js +73 -73
  25. package/processing/mealTypeCount.js +133 -133
  26. package/processing/timeblocksAvailable.js +167 -167
  27. package/reservation_data/counter.js +64 -64
  28. package/restaurant_data/exceptions.js +149 -149
  29. package/restaurant_data/openinghours.js +123 -123
  30. package/simulateTableAssignment.js +709 -699
  31. package/tableHelpers.js +178 -178
  32. package/tables/time/parseTime.js +19 -19
  33. package/tables/time/shifts.js +7 -7
  34. package/tables/utils/calculateDistance.js +13 -13
  35. package/tables/utils/isTableFreeForAllSlots.js +14 -14
  36. package/tables/utils/isTemporaryTableValid.js +39 -39
  37. package/test/test_counter.js +194 -194
  38. package/test/test_dailyCount.js +81 -81
  39. package/test/test_datesAvailable.js +106 -106
  40. package/test/test_exceptions.js +172 -172
  41. package/test/test_isDateAvailable.js +330 -330
  42. package/test/test_mealTypeCount.js +54 -54
  43. package/test/test_timesAvailable.js +88 -88
  44. package/test-meal-stop-fix.js +147 -147
  45. package/test-meal-stop-simple.js +93 -93
  46. 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
- * assignTablesIfPossible
202
- * Attempts to assign tables to a reservation based on availability and constraints.
203
- * Modifies the reservation object by attaching 'tables' and 'tableIds' if successful.
204
- */
205
- async function assignTablesIfPossible({
206
- db,
207
- reservation,
208
- enforceTableAvailability
209
- }) {
210
- const restaurantId = reservation.restaurantId;
211
- const date = reservation.date;
212
- const time = reservation.time;
213
- const guests = reservation.guests;
214
-
215
- // 1) First, fetch restaurant data (which contains the floors information)
216
- const restaurantSettings = await db.collection('restaurants').findOne({ _id: restaurantId });
217
- if (!restaurantSettings) {
218
- if (enforceTableAvailability) {
219
- throw new Error('Restaurant settings not found.');
220
- } else {
221
- return; // Non-enforcing: skip table assignment
222
- }
223
- }
224
-
225
- // 2) Get floors data directly from the restaurant document
226
- const floorsData = restaurantSettings.floors;
227
- if (!floorsData || !Array.isArray(floorsData) || floorsData.length === 0) {
228
- if (enforceTableAvailability) {
229
- throw new Error('No floors data found in restaurant document.');
230
- } else {
231
- return; // Non-enforcing: skip table assignment
232
- }
233
- }
234
-
235
- // 3) Collect all "Tafel" tables
236
- let allTables = [];
237
- floorsData.forEach(floor => {
238
- if (floor.tables && Array.isArray(floor.tables)) {
239
- floor.tables.forEach(tbl => {
240
- if (tbl.objectType === "Tafel") {
241
- allTables.push({
242
- tableId: tbl.id,
243
- tableNumber: parseInt(tbl.tableNumber.$numberInt || tbl.tableNumber),
244
- minCapacity: parseInt(tbl.minCapacity.$numberInt || tbl.minCapacity),
245
- maxCapacity: parseInt(tbl.maxCapacity.$numberInt || tbl.maxCapacity),
246
- priority: parseInt(tbl.priority.$numberInt || tbl.priority),
247
- x: parseInt(tbl.x.$numberInt || tbl.x),
248
- y: parseInt(tbl.y.$numberInt || tbl.y),
249
- isTemporary: tbl.isTemporary === true, // Added for temporary tables
250
- startDate: tbl.startDate || null, // Added for temporary tables
251
- endDate: tbl.endDate || null, // Added for temporary tables
252
- application: tbl.application || null // Added for temporary tables
253
- });
254
- }
255
- });
256
- }
257
- });
258
-
259
- // 4) If no tables found
260
- if (!allTables.length) {
261
- if (enforceTableAvailability) {
262
- throw new Error('No tables found for this restaurant.');
263
- } else {
264
- return; // Non-enforcing: skip table assignment
265
- }
266
- }
267
-
268
- // 5) Sort the tables
269
- allTables.sort((a, b) => {
270
- if (a.maxCapacity !== b.maxCapacity) {
271
- return a.maxCapacity - b.maxCapacity;
272
- }
273
- if (a.priority !== b.priority) {
274
- return a.priority - b.priority;
275
- }
276
- return a.minCapacity - b.minCapacity;
277
- });
278
-
279
- // 6) Get duration and interval settings
280
- const duurReservatie = parseInt(
281
- restaurantSettings["general-settings"]?.duurReservatie?.$numberInt || restaurantSettings["general-settings"]?.duurReservatie || 120
282
- );
283
- const intervalReservatie = parseInt(
284
- restaurantSettings["general-settings"]?.intervalReservatie?.$numberInt || restaurantSettings["general-settings"]?.intervalReservatie || 30
285
- );
286
-
287
- // 7) Compute the requiredSlots for this reservation
288
- const requiredSlots = computeRequiredSlots(time, duurReservatie, intervalReservatie);
289
-
290
- // 8) Get overlapping reservations (same date, same restaurant)
291
- const overlappingReservations = await db.collection('reservations').find({
292
- restaurantId: restaurantId,
293
- date: date
294
- }).toArray();
295
-
296
- // 9) Build tableOccupiedSlots map
297
- let tableOccupiedSlots = {}; // { [tableNumber]: Set([...slots]) }
298
- for (let r of overlappingReservations) {
299
- // No need to skip the current reservation as it's not yet inserted
300
-
301
- // compute that reservation's time slots
302
- const rDuration = duurReservatie; // assuming same duration
303
- const rSlots = computeRequiredSlots(r.time, rDuration, intervalReservatie);
304
-
305
- if (r.tables) {
306
- for (let tn of r.tables) {
307
- if (!tableOccupiedSlots[tn]) {
308
- tableOccupiedSlots[tn] = new Set();
309
- }
310
- rSlots.forEach(slot => {
311
- tableOccupiedSlots[tn].add(slot);
312
- });
313
- }
314
- }
315
- }
316
- // ===== 9.5) Table Grouping Attempt (moved to grouping.js) =====
317
- try {
318
- const groupingResult = tryGroupTables({
319
- restaurantSettings,
320
- allTables,
321
- guests,
322
- date,
323
- time,
324
- requiredSlots,
325
- tableOccupiedSlots,
326
- isTemporaryTableValid,
327
- isTableFreeForAllSlots,
328
- });
329
-
330
- if (groupingResult) {
331
- reservation.tables = groupingResult.tables;
332
- reservation.tableIds = groupingResult.tableIds;
333
- reservation._viaGroup = groupingResult.viaGroup;
334
- console.log(
335
- `[Grouping] via '${reservation._viaGroup}' -> tables ${reservation.tables.join(",")}`
336
- );
337
- return; // grouping wins
338
- }
339
- } catch (err) {
340
- // strict grouping throws; preserve original behavior
341
- throw err;
342
- }
343
- // ===== end 9.5) Table Grouping Attempt =====
344
-
345
- // 10) Single-Table Attempt
346
- for (let t of allTables) {
347
- // Check if temporary table is valid for this date/time
348
- if (!isTemporaryTableValid(t, date, time)) {
349
- continue; // Skip invalid temporary table
350
- }
351
-
352
- if (
353
- t.minCapacity <= guests &&
354
- guests <= t.maxCapacity &&
355
- isTableFreeForAllSlots(t.tableNumber, requiredSlots, tableOccupiedSlots)
356
- ) {
357
- // Assign this table to the reservation
358
- reservation.tables = [t.tableNumber];
359
- reservation.tableIds = [t.tableId];
360
- console.log(`Reservation assigned to table: ${t.tableNumber}`);
361
- return; // Assignment successful
362
- }
363
- }
364
-
365
- // 11) Multi-Table Attempt
366
- let best = { minDistance: Infinity, tables: [] };
367
-
368
- findMultiTableCombination(
369
- allTables,
370
- guests,
371
- 0,
372
- [],
373
- best,
374
- requiredSlots,
375
- tableOccupiedSlots,
376
- date, // Pass date for temporary table validation
377
- time // Pass time for temporary table validation
378
- );
379
-
380
- if (best.tables.length > 0) {
381
- // Assign the best combination to the reservation
382
- reservation.tables = best.tables.map(t => t.tableNumber);
383
- reservation.tableIds = best.tables.map(t => t.tableId);
384
- console.log(`Reservation assigned to tables: ${reservation.tables.join(', ')}`);
385
- return; // Assignment successful
386
- } else {
387
- // If no valid table combo found, either fail (if enforce) or do nothing (if not enforce)
388
- if (enforceTableAvailability) {
389
- throw new Error('Unable to find enough tables for this reservation with enforcement on.');
390
- } else {
391
- console.log('No tables available, but non-enforcing mode => continuing without assignment.');
392
- }
393
- }
394
- }
395
-
396
- module.exports = {
397
- assignTablesIfPossible
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
+ };