@holoscript/plugin-restaurant 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/restaurantsolver.test.ts +46 -34
- package/src/index.ts +28 -5
- package/src/restaurantsolver.ts +93 -39
- package/src/traits/KitchenDisplayTrait.ts +57 -15
- package/src/traits/MenuTrait.ts +55 -8
- package/src/traits/OrderTrait.ts +53 -11
- package/src/traits/TableManagementTrait.ts +62 -15
- package/src/traits/types.ts +22 -4
- package/tsconfig.json +5 -1
- package/LICENSE +0 -21
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holoscript/plugin-restaurant",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"peerDependencies": {
|
|
6
|
-
"@holoscript/core": "8.0.
|
|
6
|
+
"@holoscript/core": ">=8.0.0"
|
|
7
7
|
},
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "vitest run --passWithNoTests",
|
|
11
11
|
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
12
|
}
|
|
13
|
-
}
|
|
13
|
+
}
|
|
@@ -22,15 +22,15 @@ import {
|
|
|
22
22
|
describe('tableAssignment', () => {
|
|
23
23
|
const tables = [
|
|
24
24
|
{ id: 'T1', capacity: 2, section: 'patio', available: true },
|
|
25
|
-
{ id: 'T2', capacity: 4, section: 'main',
|
|
26
|
-
{ id: 'T3', capacity: 6, section: 'main',
|
|
27
|
-
{ id: 'T4', capacity: 8, section: 'bar',
|
|
25
|
+
{ id: 'T2', capacity: 4, section: 'main', available: true },
|
|
26
|
+
{ id: 'T3', capacity: 6, section: 'main', available: true },
|
|
27
|
+
{ id: 'T4', capacity: 8, section: 'bar', available: true },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
it('party of 2 assigned to smallest fitting table (best-fit)', () => {
|
|
31
31
|
const parties = [{ id: 'P1', partySize: 2, priorityGuest: false }];
|
|
32
32
|
const r = tableAssignment(tables, parties);
|
|
33
|
-
const assigned = r.assignments.find(a => a.partyId === 'P1');
|
|
33
|
+
const assigned = r.assignments.find((a) => a.partyId === 'P1');
|
|
34
34
|
expect(assigned!.tableCapacity).toBeGreaterThanOrEqual(2);
|
|
35
35
|
// Best-fit: should assign T1 (cap=2) over T2 (cap=4)
|
|
36
36
|
expect(assigned!.tableCapacity).toBe(2);
|
|
@@ -39,11 +39,11 @@ describe('tableAssignment', () => {
|
|
|
39
39
|
it('priority guests seated before regular guests', () => {
|
|
40
40
|
const parties = [
|
|
41
41
|
{ id: 'Regular', partySize: 4, priorityGuest: false },
|
|
42
|
-
{ id: 'VIP',
|
|
42
|
+
{ id: 'VIP', partySize: 4, priorityGuest: true },
|
|
43
43
|
];
|
|
44
44
|
const r = tableAssignment(tables, parties);
|
|
45
45
|
// VIP should be assigned (both should fit since we have 2 4+ tables)
|
|
46
|
-
expect(r.assignments.some(a => a.partyId === 'VIP')).toBe(true);
|
|
46
|
+
expect(r.assignments.some((a) => a.partyId === 'VIP')).toBe(true);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
it('party larger than any table goes to unassigned', () => {
|
|
@@ -55,14 +55,14 @@ describe('tableAssignment', () => {
|
|
|
55
55
|
it('utilization = partySize / tableCapacity', () => {
|
|
56
56
|
const parties = [{ id: 'P1', partySize: 3, priorityGuest: false }];
|
|
57
57
|
const r = tableAssignment(tables, parties);
|
|
58
|
-
const assigned = r.assignments.find(a => a.partyId === 'P1');
|
|
58
|
+
const assigned = r.assignments.find((a) => a.partyId === 'P1');
|
|
59
59
|
expect(assigned!.utilization).toBeCloseTo(assigned!.partySize / assigned!.tableCapacity, 4);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('section preference respected when available', () => {
|
|
63
63
|
const parties = [{ id: 'P1', partySize: 2, preferredSection: 'patio', priorityGuest: false }];
|
|
64
64
|
const r = tableAssignment(tables, parties);
|
|
65
|
-
const assigned = r.assignments.find(a => a.partyId === 'P1');
|
|
65
|
+
const assigned = r.assignments.find((a) => a.partyId === 'P1');
|
|
66
66
|
expect(assigned!.sectionMatch).toBe(true);
|
|
67
67
|
expect(assigned!.tableId).toBe('T1');
|
|
68
68
|
});
|
|
@@ -84,9 +84,9 @@ describe('tableAssignment', () => {
|
|
|
84
84
|
describe('kitchenQueueScheduler', () => {
|
|
85
85
|
const tickets = [
|
|
86
86
|
{ id: 'T1', estimatedMinutes: 15, priority: 2 as const, tableId: 'A', items: ['burger'] },
|
|
87
|
-
{ id: 'T2', estimatedMinutes: 8,
|
|
88
|
-
{ id: 'T3', estimatedMinutes: 20, priority: 2 as const, tableId: 'C', items: ['steak']
|
|
89
|
-
{ id: 'T4', estimatedMinutes: 5,
|
|
87
|
+
{ id: 'T2', estimatedMinutes: 8, priority: 1 as const, tableId: 'B', items: ['soup'] },
|
|
88
|
+
{ id: 'T3', estimatedMinutes: 20, priority: 2 as const, tableId: 'C', items: ['steak'] },
|
|
89
|
+
{ id: 'T4', estimatedMinutes: 5, priority: 3 as const, tableId: 'D', items: ['dessert'] },
|
|
90
90
|
];
|
|
91
91
|
|
|
92
92
|
it('priority 1 ticket scheduled first', () => {
|
|
@@ -115,8 +115,8 @@ describe('kitchenQueueScheduler', () => {
|
|
|
115
115
|
it('within same priority: shorter job goes first (SJF)', () => {
|
|
116
116
|
const r = kitchenQueueScheduler(tickets);
|
|
117
117
|
// Priority 2 tickets: T1 (15min) and T3 (20min) → T1 before T3
|
|
118
|
-
const t1pos = r.sequence.findIndex(s => s.ticketId === 'T1');
|
|
119
|
-
const t3pos = r.sequence.findIndex(s => s.ticketId === 'T3');
|
|
118
|
+
const t1pos = r.sequence.findIndex((s) => s.ticketId === 'T1');
|
|
119
|
+
const t3pos = r.sequence.findIndex((s) => s.ticketId === 'T3');
|
|
120
120
|
expect(t1pos).toBeLessThan(t3pos);
|
|
121
121
|
});
|
|
122
122
|
|
|
@@ -140,33 +140,33 @@ describe('menuEngineering', () => {
|
|
|
140
140
|
* Dogs: low popularity + low margin
|
|
141
141
|
*/
|
|
142
142
|
const items = [
|
|
143
|
-
{ id: 'burger', name: 'Burger',
|
|
144
|
-
{ id: 'pasta',
|
|
145
|
-
{ id: 'lobster',name: 'Lobster', popularity: 20,
|
|
146
|
-
{ id: 'salad',
|
|
143
|
+
{ id: 'burger', name: 'Burger', popularity: 100, contributionMargin: 12.0 }, // star — avg margin = (12+4+25+3)/4 = 11; 12 > 11 → star
|
|
144
|
+
{ id: 'pasta', name: 'Pasta', popularity: 80, contributionMargin: 4.0 }, // plow-horse
|
|
145
|
+
{ id: 'lobster', name: 'Lobster', popularity: 20, contributionMargin: 25.0 }, // puzzle
|
|
146
|
+
{ id: 'salad', name: 'Salad', popularity: 30, contributionMargin: 3.0 }, // dog
|
|
147
147
|
];
|
|
148
148
|
|
|
149
149
|
it('burger (high pop, high margin) → star', () => {
|
|
150
150
|
const r = menuEngineering(items);
|
|
151
|
-
const burger = r.items.find(i => i.id === 'burger');
|
|
151
|
+
const burger = r.items.find((i) => i.id === 'burger');
|
|
152
152
|
expect(burger!.category).toBe('star');
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
it('pasta (high pop, low margin) → plow-horse', () => {
|
|
156
156
|
const r = menuEngineering(items);
|
|
157
|
-
const pasta = r.items.find(i => i.id === 'pasta');
|
|
157
|
+
const pasta = r.items.find((i) => i.id === 'pasta');
|
|
158
158
|
expect(pasta!.category).toBe('plow-horse');
|
|
159
159
|
});
|
|
160
160
|
|
|
161
161
|
it('lobster (low pop, high margin) → puzzle', () => {
|
|
162
162
|
const r = menuEngineering(items);
|
|
163
|
-
const lobster = r.items.find(i => i.id === 'lobster');
|
|
163
|
+
const lobster = r.items.find((i) => i.id === 'lobster');
|
|
164
164
|
expect(lobster!.category).toBe('puzzle');
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
it('salad (low pop, low margin) → dog', () => {
|
|
168
168
|
const r = menuEngineering(items);
|
|
169
|
-
const salad = r.items.find(i => i.id === 'salad');
|
|
169
|
+
const salad = r.items.find((i) => i.id === 'salad');
|
|
170
170
|
expect(salad!.category).toBe('dog');
|
|
171
171
|
});
|
|
172
172
|
|
|
@@ -178,7 +178,11 @@ describe('menuEngineering', () => {
|
|
|
178
178
|
|
|
179
179
|
it('categoryCount sums to total items', () => {
|
|
180
180
|
const r = menuEngineering(items);
|
|
181
|
-
const total =
|
|
181
|
+
const total =
|
|
182
|
+
r.categoryCount.star +
|
|
183
|
+
r.categoryCount['plow-horse'] +
|
|
184
|
+
r.categoryCount.puzzle +
|
|
185
|
+
r.categoryCount.dog;
|
|
182
186
|
expect(total).toBe(items.length);
|
|
183
187
|
});
|
|
184
188
|
|
|
@@ -196,7 +200,7 @@ describe('foodCostAnalysis', () => {
|
|
|
196
200
|
*/
|
|
197
201
|
const lines = [
|
|
198
202
|
{ item: 'burger', ingredientCostUSD: 3, sellingPriceUSD: 12, unitsSold: 100 },
|
|
199
|
-
{ item: 'steak',
|
|
203
|
+
{ item: 'steak', ingredientCostUSD: 15, sellingPriceUSD: 35, unitsSold: 50 },
|
|
200
204
|
];
|
|
201
205
|
|
|
202
206
|
it('per-item foodCostPct = ingredient / price', () => {
|
|
@@ -218,14 +222,16 @@ describe('foodCostAnalysis', () => {
|
|
|
218
222
|
|
|
219
223
|
it('overTarget=true when blended cost exceeds target', () => {
|
|
220
224
|
// Steak-heavy menu at 42.9% → over standard 30% target
|
|
221
|
-
const heavySteak = [
|
|
222
|
-
|
|
225
|
+
const heavySteak = [
|
|
226
|
+
{ item: 'steak', ingredientCostUSD: 15, sellingPriceUSD: 35, unitsSold: 200 },
|
|
227
|
+
];
|
|
228
|
+
const r = foodCostAnalysis(heavySteak, 0.3);
|
|
223
229
|
expect(r.overTarget).toBe(true);
|
|
224
230
|
});
|
|
225
231
|
|
|
226
232
|
it('overTarget=false when within target', () => {
|
|
227
233
|
const lowCost = [{ item: 'drink', ingredientCostUSD: 1, sellingPriceUSD: 8, unitsSold: 100 }];
|
|
228
|
-
const r = foodCostAnalysis(lowCost, 0.
|
|
234
|
+
const r = foodCostAnalysis(lowCost, 0.3);
|
|
229
235
|
expect(r.overTarget).toBe(false);
|
|
230
236
|
});
|
|
231
237
|
|
|
@@ -238,14 +244,14 @@ describe('foodCostAnalysis', () => {
|
|
|
238
244
|
|
|
239
245
|
describe('turnTimePredictor', () => {
|
|
240
246
|
it('dinner base is longer than lunch', () => {
|
|
241
|
-
const lunch
|
|
247
|
+
const lunch = turnTimePredictor({ partySize: 2, mealPeriod: 'lunch', specialEvent: false });
|
|
242
248
|
const dinner = turnTimePredictor({ partySize: 2, mealPeriod: 'dinner', specialEvent: false });
|
|
243
249
|
expect(dinner.predictedTurnMin).toBeGreaterThan(lunch.predictedTurnMin);
|
|
244
250
|
});
|
|
245
251
|
|
|
246
252
|
it('special event adds to turn time', () => {
|
|
247
|
-
const normal
|
|
248
|
-
const special = turnTimePredictor({ partySize: 2, mealPeriod: 'dinner', specialEvent: true
|
|
253
|
+
const normal = turnTimePredictor({ partySize: 2, mealPeriod: 'dinner', specialEvent: false });
|
|
254
|
+
const special = turnTimePredictor({ partySize: 2, mealPeriod: 'dinner', specialEvent: true });
|
|
249
255
|
expect(special.predictedTurnMin).toBeGreaterThan(normal.predictedTurnMin);
|
|
250
256
|
});
|
|
251
257
|
|
|
@@ -262,13 +268,15 @@ describe('turnTimePredictor', () => {
|
|
|
262
268
|
|
|
263
269
|
it('confidenceInterval spans ±10% of predictedTurnMin', () => {
|
|
264
270
|
const r = turnTimePredictor({ partySize: 4, mealPeriod: 'dinner', specialEvent: false });
|
|
265
|
-
const margin = r.predictedTurnMin * 0.
|
|
271
|
+
const margin = r.predictedTurnMin * 0.1;
|
|
266
272
|
expect(r.confidenceInterval[0]).toBeCloseTo(r.predictedTurnMin - margin, 1);
|
|
267
273
|
expect(r.confidenceInterval[1]).toBeCloseTo(r.predictedTurnMin + margin, 1);
|
|
268
274
|
});
|
|
269
275
|
|
|
270
276
|
it('throws for partySize < 1', () => {
|
|
271
|
-
expect(() =>
|
|
277
|
+
expect(() =>
|
|
278
|
+
turnTimePredictor({ partySize: 0, mealPeriod: 'dinner', specialEvent: false })
|
|
279
|
+
).toThrow();
|
|
272
280
|
});
|
|
273
281
|
});
|
|
274
282
|
|
|
@@ -276,7 +284,9 @@ describe('turnTimePredictor', () => {
|
|
|
276
284
|
|
|
277
285
|
describe('buildRestaurantReceipt', () => {
|
|
278
286
|
it('plugin=restaurant and CAEL event correct', () => {
|
|
279
|
-
const menu = menuEngineering([
|
|
287
|
+
const menu = menuEngineering([
|
|
288
|
+
{ id: 'burger', name: 'Burger', popularity: 100, contributionMargin: 8 },
|
|
289
|
+
]);
|
|
280
290
|
const receipt = buildRestaurantReceipt({ menuEngineering: menu, converged: true });
|
|
281
291
|
expect(receipt.plugin).toBe('restaurant');
|
|
282
292
|
expect(receipt.cael.event).toBe('restaurant.operations_analysis');
|
|
@@ -284,7 +294,9 @@ describe('buildRestaurantReceipt', () => {
|
|
|
284
294
|
});
|
|
285
295
|
|
|
286
296
|
it('accepted=true for efficient operation', () => {
|
|
287
|
-
const foodCost = foodCostAnalysis([
|
|
297
|
+
const foodCost = foodCostAnalysis([
|
|
298
|
+
{ item: 'drink', ingredientCostUSD: 1, sellingPriceUSD: 8, unitsSold: 100 },
|
|
299
|
+
]);
|
|
288
300
|
const receipt = buildRestaurantReceipt({ foodCost, converged: true });
|
|
289
301
|
expect(receipt.acceptance.accepted).toBe(true);
|
|
290
302
|
});
|
|
@@ -292,7 +304,7 @@ describe('buildRestaurantReceipt', () => {
|
|
|
292
304
|
it('accepted=false when food cost over target', () => {
|
|
293
305
|
const foodCost = foodCostAnalysis(
|
|
294
306
|
[{ item: 'steak', ingredientCostUSD: 15, sellingPriceUSD: 35, unitsSold: 100 }],
|
|
295
|
-
0.
|
|
307
|
+
0.3 // 42.9% actual > 30% target
|
|
296
308
|
);
|
|
297
309
|
const receipt = buildRestaurantReceipt({ foodCost, converged: true });
|
|
298
310
|
expect(receipt.acceptance.accepted).toBe(false);
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
export * from './restaurantsolver';
|
|
2
2
|
export { createMenuHandler, type MenuConfig, type MenuItem } from './traits/MenuTrait';
|
|
3
|
-
export {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export {
|
|
4
|
+
createOrderHandler,
|
|
5
|
+
type OrderConfig,
|
|
6
|
+
type OrderItem,
|
|
7
|
+
type OrderStatus,
|
|
8
|
+
} from './traits/OrderTrait';
|
|
9
|
+
export {
|
|
10
|
+
createKitchenDisplayHandler,
|
|
11
|
+
type KitchenDisplayConfig,
|
|
12
|
+
type KitchenTicket,
|
|
13
|
+
} from './traits/KitchenDisplayTrait';
|
|
14
|
+
export {
|
|
15
|
+
createTableManagementHandler,
|
|
16
|
+
type TableManagementConfig,
|
|
17
|
+
type Table,
|
|
18
|
+
type TableStatus,
|
|
19
|
+
} from './traits/TableManagementTrait';
|
|
6
20
|
export * from './traits/types';
|
|
7
21
|
|
|
8
22
|
import { createMenuHandler } from './traits/MenuTrait';
|
|
@@ -10,5 +24,14 @@ import { createOrderHandler } from './traits/OrderTrait';
|
|
|
10
24
|
import { createKitchenDisplayHandler } from './traits/KitchenDisplayTrait';
|
|
11
25
|
import { createTableManagementHandler } from './traits/TableManagementTrait';
|
|
12
26
|
|
|
13
|
-
export const pluginMeta = {
|
|
14
|
-
|
|
27
|
+
export const pluginMeta = {
|
|
28
|
+
name: '@holoscript/plugin-restaurant',
|
|
29
|
+
version: '1.0.0',
|
|
30
|
+
traits: ['menu', 'order', 'kitchen_display', 'table_management'],
|
|
31
|
+
};
|
|
32
|
+
export const traitHandlers = [
|
|
33
|
+
createMenuHandler(),
|
|
34
|
+
createOrderHandler(),
|
|
35
|
+
createKitchenDisplayHandler(),
|
|
36
|
+
createTableManagementHandler(),
|
|
37
|
+
];
|
package/src/restaurantsolver.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface KitchenQueueResult {
|
|
|
64
64
|
endMin: number;
|
|
65
65
|
tableId: string;
|
|
66
66
|
}>;
|
|
67
|
-
makespan: number;
|
|
67
|
+
makespan: number; // total completion time minutes
|
|
68
68
|
avgWaitMin: number;
|
|
69
69
|
avgFlowMin: number;
|
|
70
70
|
}
|
|
@@ -86,8 +86,8 @@ export interface MenuEngineeringResult {
|
|
|
86
86
|
name: string;
|
|
87
87
|
popularity: number;
|
|
88
88
|
contributionMargin: number;
|
|
89
|
-
popularityIndex: number;
|
|
90
|
-
marginIndex: number;
|
|
89
|
+
popularityIndex: number; // vs average popularity
|
|
90
|
+
marginIndex: number; // vs average margin
|
|
91
91
|
category: MenuCategory;
|
|
92
92
|
}>;
|
|
93
93
|
averagePopularity: number;
|
|
@@ -134,12 +134,12 @@ export interface RestaurantReceiptOptions {
|
|
|
134
134
|
*/
|
|
135
135
|
export function tableAssignment(
|
|
136
136
|
tables: RestaurantTable[],
|
|
137
|
-
parties: PartyRequest[]
|
|
137
|
+
parties: PartyRequest[]
|
|
138
138
|
): TableAssignmentResult {
|
|
139
139
|
if (tables.length === 0) throw new Error('No tables available');
|
|
140
140
|
if (parties.length === 0) throw new Error('No parties to seat');
|
|
141
141
|
|
|
142
|
-
const available = tables.map(t => ({ ...t }));
|
|
142
|
+
const available = tables.map((t) => ({ ...t }));
|
|
143
143
|
const sorted = [...parties].sort((a, b) => {
|
|
144
144
|
// Priority guests first, then by descending party size
|
|
145
145
|
if (a.priorityGuest !== b.priorityGuest) return a.priorityGuest ? -1 : 1;
|
|
@@ -151,11 +151,16 @@ export function tableAssignment(
|
|
|
151
151
|
|
|
152
152
|
for (const party of sorted) {
|
|
153
153
|
// Best-fit: find smallest available table that fits
|
|
154
|
-
const candidates = available.filter(t => t.available && t.capacity >= party.partySize);
|
|
155
|
-
if (candidates.length === 0) {
|
|
154
|
+
const candidates = available.filter((t) => t.available && t.capacity >= party.partySize);
|
|
155
|
+
if (candidates.length === 0) {
|
|
156
|
+
unassigned.push(party.id);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
156
159
|
|
|
157
160
|
// Prefer preferred section, then smallest-capacity table
|
|
158
|
-
const sectionMatch = candidates.filter(
|
|
161
|
+
const sectionMatch = candidates.filter(
|
|
162
|
+
(t) => !party.preferredSection || t.section === party.preferredSection
|
|
163
|
+
);
|
|
159
164
|
const pool = sectionMatch.length > 0 ? sectionMatch : candidates;
|
|
160
165
|
pool.sort((a, b) => a.capacity - b.capacity);
|
|
161
166
|
const table = pool[0];
|
|
@@ -174,7 +179,11 @@ export function tableAssignment(
|
|
|
174
179
|
const seatedSeats = assignments.reduce((a, x) => a + x.partySize, 0);
|
|
175
180
|
const totalSeats = tables.reduce((a, t) => a + t.capacity, 0);
|
|
176
181
|
|
|
177
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
assignments,
|
|
184
|
+
unassigned,
|
|
185
|
+
overallUtilization: totalSeats > 0 ? seatedSeats / totalSeats : 0,
|
|
186
|
+
};
|
|
178
187
|
}
|
|
179
188
|
|
|
180
189
|
// ─── Kitchen Queue (SJF with priority) ───────────────────────────────────────
|
|
@@ -188,12 +197,13 @@ export function kitchenQueueScheduler(tickets: KitchenTicket[]): KitchenQueueRes
|
|
|
188
197
|
|
|
189
198
|
// Sort: priority level first, then shortest cook time
|
|
190
199
|
const sorted = [...tickets].sort((a, b) =>
|
|
191
|
-
a.priority !== b.priority ? a.priority - b.priority : a.estimatedMinutes - b.estimatedMinutes
|
|
200
|
+
a.priority !== b.priority ? a.priority - b.priority : a.estimatedMinutes - b.estimatedMinutes
|
|
192
201
|
);
|
|
193
202
|
|
|
194
203
|
const sequence: KitchenQueueResult['sequence'] = [];
|
|
195
204
|
let time = 0;
|
|
196
|
-
let totalWait = 0,
|
|
205
|
+
let totalWait = 0,
|
|
206
|
+
totalFlow = 0;
|
|
197
207
|
|
|
198
208
|
for (const ticket of sorted) {
|
|
199
209
|
const start = time;
|
|
@@ -225,47 +235,75 @@ export function menuEngineering(items: MenuItem[]): MenuEngineeringResult {
|
|
|
225
235
|
if (items.length === 0) throw new Error('No menu items');
|
|
226
236
|
|
|
227
237
|
const avgPopularity = items.reduce((s, i) => s + i.popularity, 0) / items.length;
|
|
228
|
-
const avgMargin
|
|
238
|
+
const avgMargin = items.reduce((s, i) => s + i.contributionMargin, 0) / items.length;
|
|
229
239
|
|
|
230
|
-
const analyzed = items.map(item => {
|
|
240
|
+
const analyzed = items.map((item) => {
|
|
231
241
|
const popularityIndex = item.popularity / avgPopularity;
|
|
232
|
-
const marginIndex
|
|
242
|
+
const marginIndex = item.contributionMargin / avgMargin;
|
|
233
243
|
const category: MenuCategory =
|
|
234
|
-
popularityIndex >= 1 && marginIndex >= 1
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
244
|
+
popularityIndex >= 1 && marginIndex >= 1
|
|
245
|
+
? 'star'
|
|
246
|
+
: popularityIndex >= 1 && marginIndex < 1
|
|
247
|
+
? 'plow-horse'
|
|
248
|
+
: popularityIndex < 1 && marginIndex >= 1
|
|
249
|
+
? 'puzzle'
|
|
250
|
+
: 'dog';
|
|
251
|
+
return {
|
|
252
|
+
id: item.id,
|
|
253
|
+
name: item.name,
|
|
254
|
+
popularity: item.popularity,
|
|
255
|
+
contributionMargin: item.contributionMargin,
|
|
256
|
+
popularityIndex,
|
|
257
|
+
marginIndex,
|
|
258
|
+
category,
|
|
259
|
+
};
|
|
238
260
|
});
|
|
239
261
|
|
|
240
|
-
const categoryCount: Record<MenuCategory, number> = {
|
|
262
|
+
const categoryCount: Record<MenuCategory, number> = {
|
|
263
|
+
star: 0,
|
|
264
|
+
'plow-horse': 0,
|
|
265
|
+
puzzle: 0,
|
|
266
|
+
dog: 0,
|
|
267
|
+
};
|
|
241
268
|
for (const a of analyzed) categoryCount[a.category]++;
|
|
242
269
|
|
|
243
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
items: analyzed,
|
|
272
|
+
averagePopularity: avgPopularity,
|
|
273
|
+
averageMargin: avgMargin,
|
|
274
|
+
categoryCount,
|
|
275
|
+
};
|
|
244
276
|
}
|
|
245
277
|
|
|
246
278
|
// ─── Food Cost Optimizer ──────────────────────────────────────────────────────
|
|
247
279
|
|
|
248
|
-
export function foodCostAnalysis(
|
|
249
|
-
lines: FoodCostLine[],
|
|
250
|
-
targetFoodCostPct = 0.30,
|
|
251
|
-
): FoodCostResult {
|
|
280
|
+
export function foodCostAnalysis(lines: FoodCostLine[], targetFoodCostPct = 0.3): FoodCostResult {
|
|
252
281
|
if (lines.length === 0) throw new Error('No food cost lines');
|
|
253
|
-
if (targetFoodCostPct <= 0 || targetFoodCostPct >= 1)
|
|
282
|
+
if (targetFoodCostPct <= 0 || targetFoodCostPct >= 1)
|
|
283
|
+
throw new Error('targetFoodCostPct must be in (0,1)');
|
|
254
284
|
|
|
255
|
-
const analyzed = lines.map(l => {
|
|
256
|
-
const revenueUSD
|
|
257
|
-
const costUSD
|
|
285
|
+
const analyzed = lines.map((l) => {
|
|
286
|
+
const revenueUSD = l.sellingPriceUSD * l.unitsSold;
|
|
287
|
+
const costUSD = l.ingredientCostUSD * l.unitsSold;
|
|
258
288
|
const grossProfitUSD = revenueUSD - costUSD;
|
|
259
|
-
const foodCostPct
|
|
289
|
+
const foodCostPct = l.sellingPriceUSD > 0 ? l.ingredientCostUSD / l.sellingPriceUSD : 0;
|
|
260
290
|
return { ...l, foodCostPct, revenueUSD, costUSD, grossProfitUSD };
|
|
261
291
|
});
|
|
262
292
|
|
|
263
293
|
const totalRevenue = analyzed.reduce((s, l) => s + l.revenueUSD, 0);
|
|
264
|
-
const totalCost
|
|
294
|
+
const totalCost = analyzed.reduce((s, l) => s + l.costUSD, 0);
|
|
265
295
|
const blendedFoodCostPct = totalRevenue > 0 ? totalCost / totalRevenue : 0;
|
|
266
296
|
const variance = blendedFoodCostPct - targetFoodCostPct;
|
|
267
297
|
|
|
268
|
-
return {
|
|
298
|
+
return {
|
|
299
|
+
lines: analyzed,
|
|
300
|
+
totalRevenue,
|
|
301
|
+
totalCost,
|
|
302
|
+
blendedFoodCostPct,
|
|
303
|
+
targetFoodCostPct,
|
|
304
|
+
variance,
|
|
305
|
+
overTarget: variance > 0,
|
|
306
|
+
};
|
|
269
307
|
}
|
|
270
308
|
|
|
271
309
|
// ─── Turn Time Predictor ──────────────────────────────────────────────────────
|
|
@@ -296,11 +334,14 @@ export function turnTimePredictor(input: TurnTimePredictorInput): TurnTimeResult
|
|
|
296
334
|
|
|
297
335
|
const periodBase = input.mealPeriod === 'lunch' ? 45 : input.mealPeriod === 'brunch' ? 55 : 75;
|
|
298
336
|
const predictedTurnMin = periodBase + 5 * input.partySize + (input.specialEvent ? 15 : 0);
|
|
299
|
-
const turnsPerEvening = 480 / predictedTurnMin;
|
|
337
|
+
const turnsPerEvening = 480 / predictedTurnMin; // 8hr service / turn time
|
|
300
338
|
|
|
301
339
|
// ±10% CI (simplified residual from regression)
|
|
302
|
-
const margin = predictedTurnMin * 0.
|
|
303
|
-
const confidenceInterval: [number, number] = [
|
|
340
|
+
const margin = predictedTurnMin * 0.1;
|
|
341
|
+
const confidenceInterval: [number, number] = [
|
|
342
|
+
predictedTurnMin - margin,
|
|
343
|
+
predictedTurnMin + margin,
|
|
344
|
+
];
|
|
304
345
|
|
|
305
346
|
return { predictedTurnMin, turnsPerEvening, confidenceInterval };
|
|
306
347
|
}
|
|
@@ -318,18 +359,27 @@ export interface RestaurantAnalysisResult {
|
|
|
318
359
|
|
|
319
360
|
export function buildRestaurantReceipt(
|
|
320
361
|
result: RestaurantAnalysisResult,
|
|
321
|
-
options?: RestaurantReceiptOptions
|
|
362
|
+
options?: RestaurantReceiptOptions
|
|
322
363
|
): DomainSimulationReceipt {
|
|
323
364
|
const violations: Array<{ criterion: string; message: string }> = [];
|
|
324
365
|
|
|
325
366
|
if (result.tableAssign && result.tableAssign.unassigned.length > 0) {
|
|
326
|
-
violations.push({
|
|
367
|
+
violations.push({
|
|
368
|
+
criterion: 'unseated_parties',
|
|
369
|
+
message: `${result.tableAssign.unassigned.length} party/parties could not be seated`,
|
|
370
|
+
});
|
|
327
371
|
}
|
|
328
372
|
if (result.foodCost?.overTarget) {
|
|
329
|
-
violations.push({
|
|
373
|
+
violations.push({
|
|
374
|
+
criterion: 'food_cost',
|
|
375
|
+
message: `Blended food cost ${(result.foodCost.blendedFoodCostPct * 100).toFixed(1)}% exceeds ${(result.foodCost.targetFoodCostPct * 100).toFixed(0)}% target`,
|
|
376
|
+
});
|
|
330
377
|
}
|
|
331
378
|
if (result.queue && result.queue.makespan > 60) {
|
|
332
|
-
violations.push({
|
|
379
|
+
violations.push({
|
|
380
|
+
criterion: 'kitchen_throughput',
|
|
381
|
+
message: `Kitchen makespan ${result.queue.makespan.toFixed(0)} min exceeds 60 min service standard`,
|
|
382
|
+
});
|
|
333
383
|
}
|
|
334
384
|
|
|
335
385
|
return buildDomainSimulationReceipt({
|
|
@@ -344,7 +394,11 @@ export function buildRestaurantReceipt(
|
|
|
344
394
|
blendedFoodCostPct: result.foodCost?.blendedFoodCostPct ?? null,
|
|
345
395
|
menuStarCount: result.menuEngineering?.categoryCount.star ?? null,
|
|
346
396
|
},
|
|
347
|
-
cael: {
|
|
397
|
+
cael: {
|
|
398
|
+
version: 'cael.v1',
|
|
399
|
+
event: 'restaurant.operations_analysis',
|
|
400
|
+
solverType: 'restaurant.table-assignment',
|
|
401
|
+
},
|
|
348
402
|
acceptance: { accepted: violations.length === 0, violations },
|
|
349
403
|
});
|
|
350
404
|
}
|
|
@@ -1,31 +1,73 @@
|
|
|
1
1
|
/** @kitchen_display Trait — Kitchen display system (KDS). @trait kitchen_display */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface KitchenTicket {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export interface KitchenTicket {
|
|
5
|
+
orderId: string;
|
|
6
|
+
items: string[];
|
|
7
|
+
priority: 'normal' | 'rush' | 'vip';
|
|
8
|
+
receivedAt: number;
|
|
9
|
+
startedAt: number | null;
|
|
10
|
+
completedAt: number | null;
|
|
11
|
+
}
|
|
12
|
+
export interface KitchenDisplayConfig {
|
|
13
|
+
stations: string[];
|
|
14
|
+
maxActiveTickets: number;
|
|
15
|
+
targetPrepTimeMin: number;
|
|
16
|
+
}
|
|
17
|
+
export interface KitchenDisplayState {
|
|
18
|
+
activeTickets: KitchenTicket[];
|
|
19
|
+
completedToday: number;
|
|
20
|
+
avgPrepTimeMin: number;
|
|
21
|
+
}
|
|
7
22
|
|
|
8
|
-
const defaultConfig: KitchenDisplayConfig = {
|
|
23
|
+
const defaultConfig: KitchenDisplayConfig = {
|
|
24
|
+
stations: ['grill', 'salad', 'dessert'],
|
|
25
|
+
maxActiveTickets: 20,
|
|
26
|
+
targetPrepTimeMin: 15,
|
|
27
|
+
};
|
|
9
28
|
|
|
10
29
|
export function createKitchenDisplayHandler(): TraitHandler<KitchenDisplayConfig> {
|
|
11
|
-
return {
|
|
12
|
-
|
|
13
|
-
|
|
30
|
+
return {
|
|
31
|
+
name: 'kitchen_display',
|
|
32
|
+
defaultConfig,
|
|
33
|
+
onAttach(n: HSPlusNode, _c: KitchenDisplayConfig, ctx: TraitContext) {
|
|
34
|
+
n.__kdsState = { activeTickets: [], completedToday: 0, avgPrepTimeMin: 0 };
|
|
35
|
+
ctx.emit?.('kds:online');
|
|
36
|
+
},
|
|
37
|
+
onDetach(n: HSPlusNode, _c: KitchenDisplayConfig, ctx: TraitContext) {
|
|
38
|
+
delete n.__kdsState;
|
|
39
|
+
ctx.emit?.('kds:offline');
|
|
40
|
+
},
|
|
14
41
|
onUpdate() {},
|
|
15
42
|
onEvent(n: HSPlusNode, c: KitchenDisplayConfig, ctx: TraitContext, e: TraitEvent) {
|
|
16
|
-
const s = n.__kdsState as KitchenDisplayState | undefined;
|
|
43
|
+
const s = n.__kdsState as KitchenDisplayState | undefined;
|
|
44
|
+
if (!s) return;
|
|
17
45
|
if (e.type === 'kds:new_ticket') {
|
|
18
|
-
const ticket: KitchenTicket = {
|
|
19
|
-
|
|
20
|
-
|
|
46
|
+
const ticket: KitchenTicket = {
|
|
47
|
+
orderId: (e.payload?.orderId as string) ?? '',
|
|
48
|
+
items: (e.payload?.items as string[]) ?? [],
|
|
49
|
+
priority: (e.payload?.priority as KitchenTicket['priority']) ?? 'normal',
|
|
50
|
+
receivedAt: Date.now(),
|
|
51
|
+
startedAt: null,
|
|
52
|
+
completedAt: null,
|
|
53
|
+
};
|
|
54
|
+
if (s.activeTickets.length < c.maxActiveTickets) {
|
|
55
|
+
s.activeTickets.push(ticket);
|
|
56
|
+
ctx.emit?.('kds:ticket_received', { orderId: ticket.orderId });
|
|
57
|
+
} else ctx.emit?.('kds:queue_full');
|
|
21
58
|
}
|
|
22
59
|
if (e.type === 'kds:complete_ticket') {
|
|
23
|
-
const idx = s.activeTickets.findIndex(t => t.orderId === (e.payload?.orderId as string));
|
|
24
|
-
if (idx >= 0) {
|
|
60
|
+
const idx = s.activeTickets.findIndex((t) => t.orderId === (e.payload?.orderId as string));
|
|
61
|
+
if (idx >= 0) {
|
|
62
|
+
const t = s.activeTickets.splice(idx, 1)[0];
|
|
63
|
+
t.completedAt = Date.now();
|
|
64
|
+
s.completedToday++;
|
|
25
65
|
const prepTime = (t.completedAt - t.receivedAt) / 60000;
|
|
26
|
-
s.avgPrepTimeMin =
|
|
66
|
+
s.avgPrepTimeMin =
|
|
67
|
+
(s.avgPrepTimeMin * (s.completedToday - 1) + prepTime) / s.completedToday;
|
|
27
68
|
ctx.emit?.('kds:ticket_done', { orderId: t.orderId, prepMin: Math.round(prepTime) });
|
|
28
|
-
if (prepTime > c.targetPrepTimeMin)
|
|
69
|
+
if (prepTime > c.targetPrepTimeMin)
|
|
70
|
+
ctx.emit?.('kds:slow_ticket', { orderId: t.orderId, prepMin: Math.round(prepTime) });
|
|
29
71
|
}
|
|
30
72
|
}
|
|
31
73
|
},
|
package/src/traits/MenuTrait.ts
CHANGED
|
@@ -1,19 +1,66 @@
|
|
|
1
1
|
/** @menu Trait — Restaurant menu management. @trait menu */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface MenuItem {
|
|
5
|
-
|
|
4
|
+
export interface MenuItem {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
price: number;
|
|
8
|
+
category: string;
|
|
9
|
+
description: string;
|
|
10
|
+
allergens: string[];
|
|
11
|
+
calories?: number;
|
|
12
|
+
available: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface MenuConfig {
|
|
15
|
+
items: MenuItem[];
|
|
16
|
+
currency: string;
|
|
17
|
+
taxRate: number;
|
|
18
|
+
serviceChargePercent: number;
|
|
19
|
+
}
|
|
6
20
|
|
|
7
|
-
const defaultConfig: MenuConfig = {
|
|
21
|
+
const defaultConfig: MenuConfig = {
|
|
22
|
+
items: [],
|
|
23
|
+
currency: 'USD',
|
|
24
|
+
taxRate: 0.08,
|
|
25
|
+
serviceChargePercent: 0,
|
|
26
|
+
};
|
|
8
27
|
|
|
9
28
|
export function createMenuHandler(): TraitHandler<MenuConfig> {
|
|
10
|
-
return {
|
|
11
|
-
|
|
12
|
-
|
|
29
|
+
return {
|
|
30
|
+
name: 'menu',
|
|
31
|
+
defaultConfig,
|
|
32
|
+
onAttach(n: HSPlusNode, c: MenuConfig, ctx: TraitContext) {
|
|
33
|
+
n.__menuState = {
|
|
34
|
+
availableItems: c.items.filter((i) => i.available).length,
|
|
35
|
+
categories: [...new Set(c.items.map((i) => i.category))],
|
|
36
|
+
};
|
|
37
|
+
ctx.emit?.('menu:loaded', { items: c.items.length });
|
|
38
|
+
},
|
|
39
|
+
onDetach(n: HSPlusNode, _c: MenuConfig, ctx: TraitContext) {
|
|
40
|
+
delete n.__menuState;
|
|
41
|
+
ctx.emit?.('menu:unloaded');
|
|
42
|
+
},
|
|
13
43
|
onUpdate() {},
|
|
14
44
|
onEvent(_n: HSPlusNode, c: MenuConfig, ctx: TraitContext, e: TraitEvent) {
|
|
15
|
-
if (e.type === 'menu:search') {
|
|
16
|
-
|
|
45
|
+
if (e.type === 'menu:search') {
|
|
46
|
+
const q = ((e.payload?.query as string) ?? '').toLowerCase();
|
|
47
|
+
const results = c.items.filter(
|
|
48
|
+
(i) =>
|
|
49
|
+
i.available &&
|
|
50
|
+
(i.name.toLowerCase().includes(q) || i.category.toLowerCase().includes(q))
|
|
51
|
+
);
|
|
52
|
+
ctx.emit?.('menu:results', {
|
|
53
|
+
count: results.length,
|
|
54
|
+
items: results.map((r) => ({ id: r.id, name: r.name, price: r.price })),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (e.type === 'menu:toggle_availability') {
|
|
58
|
+
const item = c.items.find((i) => i.id === (e.payload?.itemId as string));
|
|
59
|
+
if (item) {
|
|
60
|
+
item.available = !item.available;
|
|
61
|
+
ctx.emit?.('menu:availability_changed', { item: item.name, available: item.available });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
17
64
|
},
|
|
18
65
|
};
|
|
19
66
|
}
|
package/src/traits/OrderTrait.ts
CHANGED
|
@@ -1,27 +1,69 @@
|
|
|
1
1
|
/** @order Trait — Customer order lifecycle. @trait order */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export type OrderStatus =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
export type OrderStatus =
|
|
5
|
+
| 'new'
|
|
6
|
+
| 'confirmed'
|
|
7
|
+
| 'preparing'
|
|
8
|
+
| 'ready'
|
|
9
|
+
| 'served'
|
|
10
|
+
| 'paid'
|
|
11
|
+
| 'cancelled';
|
|
12
|
+
export interface OrderItem {
|
|
13
|
+
menuItemId: string;
|
|
14
|
+
name: string;
|
|
15
|
+
quantity: number;
|
|
16
|
+
price: number;
|
|
17
|
+
modifications?: string[];
|
|
18
|
+
}
|
|
19
|
+
export interface OrderConfig {
|
|
20
|
+
tableNumber: number;
|
|
21
|
+
items: OrderItem[];
|
|
22
|
+
orderType: 'dine_in' | 'takeout' | 'delivery';
|
|
23
|
+
}
|
|
24
|
+
export interface OrderState {
|
|
25
|
+
status: OrderStatus;
|
|
26
|
+
orderId: string;
|
|
27
|
+
subtotal: number;
|
|
28
|
+
createdAt: number;
|
|
29
|
+
}
|
|
8
30
|
|
|
9
31
|
const defaultConfig: OrderConfig = { tableNumber: 0, items: [], orderType: 'dine_in' };
|
|
10
32
|
|
|
11
33
|
export function createOrderHandler(): TraitHandler<OrderConfig> {
|
|
12
|
-
return {
|
|
34
|
+
return {
|
|
35
|
+
name: 'order',
|
|
36
|
+
defaultConfig,
|
|
13
37
|
onAttach(n: HSPlusNode, c: OrderConfig, ctx: TraitContext) {
|
|
14
38
|
const subtotal = c.items.reduce((s, i) => s + i.price * i.quantity, 0);
|
|
15
|
-
n.__orderState = {
|
|
39
|
+
n.__orderState = {
|
|
40
|
+
status: 'new' as OrderStatus,
|
|
41
|
+
orderId: `ORD-${Date.now()}`,
|
|
42
|
+
subtotal,
|
|
43
|
+
createdAt: Date.now(),
|
|
44
|
+
};
|
|
16
45
|
ctx.emit?.('order:created', { table: c.tableNumber, items: c.items.length, subtotal });
|
|
17
46
|
},
|
|
18
|
-
onDetach(n: HSPlusNode, _c: OrderConfig, ctx: TraitContext) {
|
|
47
|
+
onDetach(n: HSPlusNode, _c: OrderConfig, ctx: TraitContext) {
|
|
48
|
+
delete n.__orderState;
|
|
49
|
+
ctx.emit?.('order:removed');
|
|
50
|
+
},
|
|
19
51
|
onUpdate() {},
|
|
20
52
|
onEvent(n: HSPlusNode, _c: OrderConfig, ctx: TraitContext, e: TraitEvent) {
|
|
21
|
-
const s = n.__orderState as OrderState | undefined;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (e.type === 'order:
|
|
53
|
+
const s = n.__orderState as OrderState | undefined;
|
|
54
|
+
if (!s) return;
|
|
55
|
+
const flow: OrderStatus[] = ['new', 'confirmed', 'preparing', 'ready', 'served', 'paid'];
|
|
56
|
+
if (e.type === 'order:advance') {
|
|
57
|
+
const i = flow.indexOf(s.status);
|
|
58
|
+
if (i < flow.length - 1) {
|
|
59
|
+
s.status = flow[i + 1];
|
|
60
|
+
ctx.emit?.('order:status_changed', { status: s.status, orderId: s.orderId });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (e.type === 'order:cancel') {
|
|
64
|
+
s.status = 'cancelled';
|
|
65
|
+
ctx.emit?.('order:cancelled', { orderId: s.orderId });
|
|
66
|
+
}
|
|
25
67
|
},
|
|
26
68
|
};
|
|
27
69
|
}
|
|
@@ -2,34 +2,81 @@
|
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
4
|
export type TableStatus = 'available' | 'reserved' | 'occupied' | 'cleaning' | 'blocked';
|
|
5
|
-
export interface Table {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
export interface Table {
|
|
6
|
+
id: string;
|
|
7
|
+
seats: number;
|
|
8
|
+
section: string;
|
|
9
|
+
status: TableStatus;
|
|
10
|
+
partySize?: number;
|
|
11
|
+
seatedAt?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface TableManagementConfig {
|
|
14
|
+
tables: Table[];
|
|
15
|
+
turnoverTargetMin: number;
|
|
16
|
+
waitlistEnabled: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface TableManagementState {
|
|
19
|
+
availableCount: number;
|
|
20
|
+
occupiedCount: number;
|
|
21
|
+
waitlistSize: number;
|
|
22
|
+
avgTurnoverMin: number;
|
|
23
|
+
}
|
|
8
24
|
|
|
9
|
-
const defaultConfig: TableManagementConfig = {
|
|
25
|
+
const defaultConfig: TableManagementConfig = {
|
|
26
|
+
tables: [],
|
|
27
|
+
turnoverTargetMin: 60,
|
|
28
|
+
waitlistEnabled: true,
|
|
29
|
+
};
|
|
10
30
|
|
|
11
31
|
export function createTableManagementHandler(): TraitHandler<TableManagementConfig> {
|
|
12
|
-
return {
|
|
32
|
+
return {
|
|
33
|
+
name: 'table_management',
|
|
34
|
+
defaultConfig,
|
|
13
35
|
onAttach(n: HSPlusNode, c: TableManagementConfig, ctx: TraitContext) {
|
|
14
|
-
const avail = c.tables.filter(t => t.status === 'available').length;
|
|
15
|
-
n.__tableState = {
|
|
36
|
+
const avail = c.tables.filter((t) => t.status === 'available').length;
|
|
37
|
+
n.__tableState = {
|
|
38
|
+
availableCount: avail,
|
|
39
|
+
occupiedCount: c.tables.length - avail,
|
|
40
|
+
waitlistSize: 0,
|
|
41
|
+
avgTurnoverMin: 0,
|
|
42
|
+
};
|
|
16
43
|
ctx.emit?.('tables:initialized', { total: c.tables.length, available: avail });
|
|
17
44
|
},
|
|
18
|
-
onDetach(n: HSPlusNode, _c: TableManagementConfig, ctx: TraitContext) {
|
|
45
|
+
onDetach(n: HSPlusNode, _c: TableManagementConfig, ctx: TraitContext) {
|
|
46
|
+
delete n.__tableState;
|
|
47
|
+
ctx.emit?.('tables:closed');
|
|
48
|
+
},
|
|
19
49
|
onUpdate() {},
|
|
20
50
|
onEvent(n: HSPlusNode, c: TableManagementConfig, ctx: TraitContext, e: TraitEvent) {
|
|
21
|
-
const s = n.__tableState as TableManagementState | undefined;
|
|
51
|
+
const s = n.__tableState as TableManagementState | undefined;
|
|
52
|
+
if (!s) return;
|
|
22
53
|
if (e.type === 'tables:seat') {
|
|
23
|
-
const tableId = e.payload?.tableId as string;
|
|
24
|
-
const
|
|
25
|
-
|
|
54
|
+
const tableId = e.payload?.tableId as string;
|
|
55
|
+
const party = (e.payload?.partySize as number) ?? 1;
|
|
56
|
+
const table = c.tables.find((t) => t.id === tableId);
|
|
57
|
+
if (table && table.status === 'available') {
|
|
58
|
+
table.status = 'occupied';
|
|
59
|
+
table.partySize = party;
|
|
60
|
+
table.seatedAt = Date.now();
|
|
61
|
+
s.availableCount--;
|
|
62
|
+
s.occupiedCount++;
|
|
63
|
+
ctx.emit?.('tables:seated', { table: tableId, party });
|
|
64
|
+
}
|
|
26
65
|
}
|
|
27
66
|
if (e.type === 'tables:clear') {
|
|
28
67
|
const tableId = e.payload?.tableId as string;
|
|
29
|
-
const table = c.tables.find(t => t.id === tableId);
|
|
30
|
-
if (table && table.status === 'occupied') {
|
|
68
|
+
const table = c.tables.find((t) => t.id === tableId);
|
|
69
|
+
if (table && table.status === 'occupied') {
|
|
70
|
+
table.status = 'available';
|
|
71
|
+
s.availableCount++;
|
|
72
|
+
s.occupiedCount--;
|
|
73
|
+
ctx.emit?.('tables:cleared', { table: tableId });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (e.type === 'tables:add_waitlist') {
|
|
77
|
+
s.waitlistSize++;
|
|
78
|
+
ctx.emit?.('tables:waitlist_updated', { size: s.waitlistSize });
|
|
31
79
|
}
|
|
32
|
-
if (e.type === 'tables:add_waitlist') { s.waitlistSize++; ctx.emit?.('tables:waitlist_updated', { size: s.waitlistSize }); }
|
|
33
80
|
},
|
|
34
81
|
};
|
|
35
82
|
}
|
package/src/traits/types.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
export interface HSPlusNode {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export interface HSPlusNode {
|
|
2
|
+
id?: string;
|
|
3
|
+
properties?: Record<string, unknown>;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface TraitContext {
|
|
7
|
+
emit?: (event: string, payload?: unknown) => void;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface TraitEvent {
|
|
11
|
+
type: string;
|
|
12
|
+
payload?: Record<string, unknown>;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface TraitHandler<T = unknown> {
|
|
16
|
+
name: string;
|
|
17
|
+
defaultConfig: T;
|
|
18
|
+
onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
19
|
+
onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
20
|
+
onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void;
|
|
21
|
+
onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void;
|
|
22
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.json",
|
|
3
|
+
"compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true },
|
|
4
|
+
"include": ["src"]
|
|
5
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|