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