@porulle/plugin-pos-restaurant 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +61 -0
  2. package/dist/hooks/modifier-validation.d.ts +21 -0
  3. package/dist/hooks/modifier-validation.d.ts.map +1 -0
  4. package/dist/hooks/modifier-validation.js +40 -0
  5. package/dist/hooks/table-lifecycle.d.ts +15 -0
  6. package/dist/hooks/table-lifecycle.d.ts.map +1 -0
  7. package/dist/hooks/table-lifecycle.js +40 -0
  8. package/dist/index.d.ts +60 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +169 -0
  11. package/dist/routes/kds.d.ts +9 -0
  12. package/dist/routes/kds.d.ts.map +1 -0
  13. package/dist/routes/kds.js +112 -0
  14. package/dist/routes/modifiers.d.ts +15 -0
  15. package/dist/routes/modifiers.d.ts.map +1 -0
  16. package/dist/routes/modifiers.js +117 -0
  17. package/dist/routes/operations.d.ts +34 -0
  18. package/dist/routes/operations.d.ts.map +1 -0
  19. package/dist/routes/operations.js +249 -0
  20. package/dist/routes/tables.d.ts +9 -0
  21. package/dist/routes/tables.d.ts.map +1 -0
  22. package/dist/routes/tables.js +110 -0
  23. package/dist/schema.d.ts +3831 -0
  24. package/dist/schema.d.ts.map +1 -0
  25. package/dist/schema.js +418 -0
  26. package/dist/services/alert-service.d.ts +56 -0
  27. package/dist/services/alert-service.d.ts.map +1 -0
  28. package/dist/services/alert-service.js +173 -0
  29. package/dist/services/analytics-service.d.ts +60 -0
  30. package/dist/services/analytics-service.d.ts.map +1 -0
  31. package/dist/services/analytics-service.js +178 -0
  32. package/dist/services/checklist-service.d.ts +66 -0
  33. package/dist/services/checklist-service.d.ts.map +1 -0
  34. package/dist/services/checklist-service.js +106 -0
  35. package/dist/services/kds-service.d.ts +77 -0
  36. package/dist/services/kds-service.d.ts.map +1 -0
  37. package/dist/services/kds-service.js +246 -0
  38. package/dist/services/modifier-service.d.ts +74 -0
  39. package/dist/services/modifier-service.d.ts.map +1 -0
  40. package/dist/services/modifier-service.js +180 -0
  41. package/dist/services/recipe-deduction-service.d.ts +64 -0
  42. package/dist/services/recipe-deduction-service.d.ts.map +1 -0
  43. package/dist/services/recipe-deduction-service.js +144 -0
  44. package/dist/services/recipe-service.d.ts +43 -0
  45. package/dist/services/recipe-service.d.ts.map +1 -0
  46. package/dist/services/recipe-service.js +85 -0
  47. package/dist/services/table-service.d.ts +61 -0
  48. package/dist/services/table-service.d.ts.map +1 -0
  49. package/dist/services/table-service.js +206 -0
  50. package/dist/types.d.ts +35 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +6 -0
  53. package/package.json +61 -0
  54. package/src/hooks/modifier-validation.ts +60 -0
  55. package/src/hooks/table-lifecycle.ts +50 -0
  56. package/src/index.ts +218 -0
  57. package/src/routes/kds.ts +121 -0
  58. package/src/routes/modifiers.ts +129 -0
  59. package/src/routes/operations.ts +276 -0
  60. package/src/routes/tables.ts +117 -0
  61. package/src/schema.ts +462 -0
  62. package/src/services/alert-service.ts +234 -0
  63. package/src/services/analytics-service.ts +242 -0
  64. package/src/services/checklist-service.ts +139 -0
  65. package/src/services/kds-service.ts +340 -0
  66. package/src/services/modifier-service.ts +251 -0
  67. package/src/services/recipe-deduction-service.ts +221 -0
  68. package/src/services/recipe-service.ts +115 -0
  69. package/src/services/table-service.ts +260 -0
  70. package/src/types.ts +63 -0
@@ -0,0 +1,340 @@
1
+ /**
2
+ * KDSService — Kitchen Display System station management, ticket generation,
3
+ * and status tracking.
4
+ *
5
+ * Informed by URY's KOT system:
6
+ * - URY Production Unit: item-group-based routing, per-station printers
7
+ * - URY KOT: submittable ticket per production unit, type enum, order_status,
8
+ * production_time tracking, Socket.IO broadcasting
9
+ * - URY KOT Items: course (Menu Course link), serve_priority, indicate_course,
10
+ * cancelled_qty
11
+ * - kot_execute(): compares previous vs current items, generates New Order,
12
+ * Order Modified, Partially cancelled KOTs per production unit
13
+ * - multi_print_kot(): production printer > room printer > POS printer cascade
14
+ *
15
+ * Improvements over URY:
16
+ * - Item-level status persisted to DB (not browser localStorage)
17
+ * - 4-state ticket status (pending -> preparing -> ready -> served)
18
+ * instead of URY's 2-state (Ready For Prepare -> Served)
19
+ * - prep_duration_seconds calculated and stored for analytics
20
+ * - Structured course priority on ticket items (not just display ordering)
21
+ */
22
+
23
+ import { eq, and, desc, sql } from "@porulle/core/drizzle";
24
+ import {
25
+ kdsStations,
26
+ kdsStationItemGroups,
27
+ kdsTickets,
28
+ kdsTicketItems,
29
+ } from "../schema.js";
30
+ import type {
31
+ Db,
32
+ KDSStation,
33
+ KDSStationItemGroup,
34
+ KDSTicket,
35
+ KDSTicketItem,
36
+ TicketStatus,
37
+ TicketItemStatus,
38
+ } from "../types.js";
39
+
40
+ import { Ok, Err } from "@porulle/core";
41
+ import type { PluginResult } from "@porulle/core";
42
+
43
+ export class KDSService {
44
+ constructor(private db: Db) {}
45
+
46
+ // ─── Station CRUD ──────────────────────────────────────────────────
47
+
48
+ async createStation(orgId: string, input: {
49
+ name: string;
50
+ alertThresholdMinutes?: number;
51
+ metadata?: Record<string, unknown>;
52
+ }): Promise<PluginResult<KDSStation>> {
53
+ const existing = await this.db
54
+ .select()
55
+ .from(kdsStations)
56
+ .where(and(eq(kdsStations.organizationId, orgId), eq(kdsStations.name, input.name)));
57
+
58
+ if (existing.length > 0) return Err(`Station '${input.name}' already exists`);
59
+
60
+ const rows = await this.db
61
+ .insert(kdsStations)
62
+ .values({
63
+ organizationId: orgId,
64
+ name: input.name,
65
+ alertThresholdMinutes: input.alertThresholdMinutes ?? 15,
66
+ metadata: input.metadata ?? {},
67
+ })
68
+ .returning();
69
+
70
+ return Ok(rows[0]!);
71
+ }
72
+
73
+ async listStations(orgId: string): Promise<PluginResult<KDSStation[]>> {
74
+ const rows = await this.db
75
+ .select()
76
+ .from(kdsStations)
77
+ .where(eq(kdsStations.organizationId, orgId));
78
+ return Ok(rows);
79
+ }
80
+
81
+ async updateStation(orgId: string, id: string, input: {
82
+ name?: string;
83
+ isActive?: boolean;
84
+ alertThresholdMinutes?: number;
85
+ }): Promise<PluginResult<KDSStation>> {
86
+ const rows = await this.db
87
+ .update(kdsStations)
88
+ .set({ ...input, updatedAt: new Date() })
89
+ .where(and(eq(kdsStations.id, id), eq(kdsStations.organizationId, orgId)))
90
+ .returning();
91
+
92
+ if (rows.length === 0) return Err("Station not found");
93
+ return Ok(rows[0]!);
94
+ }
95
+
96
+ // ─── Station Item Groups ──────────────────────────────────────────
97
+ // URY equivalent: URY Production Item Groups child table.
98
+
99
+ async addItemGroup(stationId: string, itemGroup: string): Promise<PluginResult<KDSStationItemGroup>> {
100
+ const rows = await this.db
101
+ .insert(kdsStationItemGroups)
102
+ .values({ stationId, itemGroup })
103
+ .returning();
104
+ return Ok(rows[0]!);
105
+ }
106
+
107
+ async removeItemGroup(stationId: string, itemGroup: string): Promise<PluginResult<{ removed: boolean }>> {
108
+ const rows = await this.db
109
+ .delete(kdsStationItemGroups)
110
+ .where(and(
111
+ eq(kdsStationItemGroups.stationId, stationId),
112
+ eq(kdsStationItemGroups.itemGroup, itemGroup),
113
+ ))
114
+ .returning();
115
+
116
+ if (rows.length === 0) return Err("Item group not found on station");
117
+ return Ok({ removed: true });
118
+ }
119
+
120
+ async getStationItemGroups(stationId: string): Promise<string[]> {
121
+ const rows = await this.db
122
+ .select({ itemGroup: kdsStationItemGroups.itemGroup })
123
+ .from(kdsStationItemGroups)
124
+ .where(eq(kdsStationItemGroups.stationId, stationId));
125
+ return rows.map((r) => r.itemGroup);
126
+ }
127
+
128
+ // ─── Ticket Generation ─────────────────────────────────────────────
129
+ // URY equivalent: kot_execute() + process_items_for_kot() + create_kot_doc().
130
+ //
131
+ // For each station, filter items matching station's item groups.
132
+ // If matching items exist, create a ticket for that station.
133
+ // If a ticket already exists for this transaction+station, set type="modified".
134
+
135
+ async generateTickets(orgId: string, input: {
136
+ transactionId: string;
137
+ items: Array<{
138
+ entityId: string;
139
+ variantId?: string;
140
+ itemName: string;
141
+ quantity: number;
142
+ itemGroup: string;
143
+ courseName?: string;
144
+ coursePriority?: number;
145
+ showCourseLabel?: boolean;
146
+ modifiers?: Array<{ name: string; priceAdjustment: number }>;
147
+ notes?: string;
148
+ }>;
149
+ tableNumber?: string;
150
+ orderType?: "dine_in" | "takeaway" | "delivery";
151
+ operatorName?: string;
152
+ comments?: string;
153
+ }): Promise<PluginResult<KDSTicket[]>> {
154
+ // Get all active stations with their item groups
155
+ const stations = await this.db
156
+ .select()
157
+ .from(kdsStations)
158
+ .where(and(eq(kdsStations.organizationId, orgId), eq(kdsStations.isActive, true)));
159
+
160
+ const tickets: KDSTicket[] = [];
161
+
162
+ for (const station of stations) {
163
+ const stationGroups = await this.getStationItemGroups(station.id);
164
+ const matchingItems = input.items.filter((item) => stationGroups.includes(item.itemGroup));
165
+
166
+ if (matchingItems.length === 0) continue;
167
+
168
+ // Check if ticket already exists for this transaction+station
169
+ const existingTickets = await this.db
170
+ .select()
171
+ .from(kdsTickets)
172
+ .where(and(
173
+ eq(kdsTickets.transactionId, input.transactionId),
174
+ eq(kdsTickets.stationId, station.id),
175
+ ));
176
+
177
+ const ticketType = existingTickets.length > 0 ? "modified" : "new_order";
178
+ const ticketNumber = await this.generateTicketNumber(station.id);
179
+
180
+ const ticketRows = await this.db
181
+ .insert(kdsTickets)
182
+ .values({
183
+ organizationId: orgId,
184
+ transactionId: input.transactionId,
185
+ stationId: station.id,
186
+ type: ticketType,
187
+ status: "pending",
188
+ tableNumber: input.tableNumber,
189
+ orderType: input.orderType ?? "dine_in",
190
+ operatorName: input.operatorName,
191
+ ticketNumber,
192
+ comments: input.comments,
193
+ })
194
+ .returning();
195
+
196
+ const ticket = ticketRows[0]!;
197
+
198
+ // Add items to ticket
199
+ for (const item of matchingItems) {
200
+ await this.db.insert(kdsTicketItems).values({
201
+ ticketId: ticket.id,
202
+ entityId: item.entityId,
203
+ variantId: item.variantId,
204
+ itemName: item.itemName,
205
+ quantity: item.quantity,
206
+ courseName: item.courseName,
207
+ coursePriority: item.coursePriority ?? 0,
208
+ showCourseLabel: item.showCourseLabel ?? false,
209
+ modifiers: item.modifiers ?? [],
210
+ notes: item.notes,
211
+ });
212
+ }
213
+
214
+ tickets.push(ticket);
215
+ }
216
+
217
+ return Ok(tickets);
218
+ }
219
+
220
+ // ─── Ticket Status Updates ─────────────────────────────────────────
221
+ // URY equivalent: serve_kot() — sets order_status="Served", production_time.
222
+ // We add intermediate states (preparing, ready) for finer-grained tracking.
223
+
224
+ async startTicket(id: string): Promise<PluginResult<KDSTicket>> {
225
+ return this.updateTicketStatus(id, "preparing", { firedAt: new Date() });
226
+ }
227
+
228
+ async readyTicket(id: string): Promise<PluginResult<KDSTicket>> {
229
+ const ticket = await this.getTicketById(id);
230
+ if (!ticket.ok) return ticket;
231
+
232
+ const prepDuration = ticket.value.firedAt
233
+ ? Math.round((Date.now() - ticket.value.firedAt.getTime()) / 1000)
234
+ : undefined;
235
+
236
+ return this.updateTicketStatus(id, "ready", {
237
+ readyAt: new Date(),
238
+ ...(prepDuration != null ? { prepDurationSeconds: prepDuration } : {}),
239
+ });
240
+ }
241
+
242
+ async serveTicket(id: string): Promise<PluginResult<KDSTicket>> {
243
+ return this.updateTicketStatus(id, "served", { servedAt: new Date() });
244
+ }
245
+
246
+ private static readonly VALID_TRANSITIONS: Record<string, string[]> = {
247
+ pending: ["preparing"],
248
+ preparing: ["ready"],
249
+ ready: ["served"],
250
+ served: [], // terminal state
251
+ };
252
+
253
+ private async updateTicketStatus(id: string, newStatus: TicketStatus, extra: Record<string, unknown> = {}): Promise<PluginResult<KDSTicket>> {
254
+ // Validate state transition
255
+ const existing = await this.db.select().from(kdsTickets).where(eq(kdsTickets.id, id));
256
+ if (existing.length === 0) return Err("Ticket not found");
257
+ const current = existing[0]!;
258
+
259
+ const allowed = KDSService.VALID_TRANSITIONS[current.status] ?? [];
260
+ if (!allowed.includes(newStatus)) {
261
+ return Err(`Cannot transition ticket from '${current.status}' to '${newStatus}'`);
262
+ }
263
+
264
+ const rows = await this.db
265
+ .update(kdsTickets)
266
+ .set({ status: newStatus, ...extra, updatedAt: new Date() })
267
+ .where(eq(kdsTickets.id, id))
268
+ .returning();
269
+
270
+ if (rows.length === 0) return Err("Ticket not found");
271
+ return Ok(rows[0]!);
272
+ }
273
+
274
+ // ─── Item Status Updates ───────────────────────────────────────────
275
+ // URY stored item strikethrough in browser localStorage only.
276
+ // We persist item status to the database.
277
+
278
+ async markItemDone(ticketId: string, itemId: string): Promise<PluginResult<KDSTicketItem>> {
279
+ const rows = await this.db
280
+ .update(kdsTicketItems)
281
+ .set({ status: "done" })
282
+ .where(and(eq(kdsTicketItems.id, itemId), eq(kdsTicketItems.ticketId, ticketId)))
283
+ .returning();
284
+
285
+ if (rows.length === 0) return Err("Ticket item not found");
286
+ return Ok(rows[0]!);
287
+ }
288
+
289
+ // ─── Queries ───────────────────────────────────────────────────────
290
+
291
+ async getTicketById(id: string): Promise<PluginResult<KDSTicket>> {
292
+ const rows = await this.db.select().from(kdsTickets).where(eq(kdsTickets.id, id));
293
+ if (rows.length === 0) return Err("Ticket not found");
294
+ return Ok(rows[0]!);
295
+ }
296
+
297
+ async listPendingTickets(orgId: string, stationId: string): Promise<PluginResult<Array<KDSTicket & { items: KDSTicketItem[] }>>> {
298
+ const tickets = await this.db
299
+ .select()
300
+ .from(kdsTickets)
301
+ .where(and(
302
+ eq(kdsTickets.organizationId, orgId),
303
+ eq(kdsTickets.stationId, stationId),
304
+ sql`${kdsTickets.status} != 'served'`,
305
+ ))
306
+ .orderBy(kdsTickets.createdAt);
307
+
308
+ const result = [];
309
+ for (const ticket of tickets) {
310
+ const items = await this.db
311
+ .select()
312
+ .from(kdsTicketItems)
313
+ .where(eq(kdsTicketItems.ticketId, ticket.id))
314
+ .orderBy(kdsTicketItems.coursePriority);
315
+
316
+ result.push({ ...ticket, items });
317
+ }
318
+
319
+ return Ok(result);
320
+ }
321
+
322
+ // ─── Helpers ───────────────────────────────────────────────────────
323
+
324
+ private async generateTicketNumber(stationId: string): Promise<string> {
325
+ const today = new Date();
326
+ const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
327
+ const startOfDayISO = startOfDay.toISOString();
328
+
329
+ const countRows = await this.db
330
+ .select({ count: sql<number>`COUNT(*)`.as("count") })
331
+ .from(kdsTickets)
332
+ .where(and(
333
+ eq(kdsTickets.stationId, stationId),
334
+ sql`${kdsTickets.createdAt} >= ${startOfDayISO}::timestamptz`,
335
+ ));
336
+
337
+ const seq = Number(countRows[0]?.count ?? 0) + 1;
338
+ return `KDS-${String(seq).padStart(4, "0")}`;
339
+ }
340
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * ModifierService — CRUD and validation for item modifier groups and options.
3
+ *
4
+ * URY uses a flat "Item Add On" child table with no grouping, no required/optional
5
+ * flags, no min/max constraints, and no price adjustments. This service implements
6
+ * structured modifier groups with full constraint validation.
7
+ *
8
+ * Validation logic (used by cart.beforeAddItem hook):
9
+ * - Required groups must have at least minSelect selections
10
+ * - No group may exceed maxSelect selections
11
+ * - Unavailable options are rejected
12
+ * - Price adjustments are summed and returned for line item total calculation
13
+ */
14
+
15
+ import { eq, and, desc } from "@porulle/core/drizzle";
16
+ import { Ok, Err } from "@porulle/core";
17
+ import type { PluginResult } from "@porulle/core";
18
+ import { posModifierGroups, posModifierOptions } from "../schema.js";
19
+ import type { Db, ModifierGroup, ModifierOption } from "../types.js";
20
+
21
+ export class ModifierService {
22
+ constructor(private db: Db) {}
23
+
24
+ // ─── Group CRUD ────────────────────────────────────────────────────
25
+
26
+ async createGroup(orgId: string, input: {
27
+ name: string;
28
+ entityId?: string;
29
+ itemGroup?: string;
30
+ isRequired?: boolean;
31
+ minSelect?: number;
32
+ maxSelect?: number;
33
+ sortOrder?: number;
34
+ }): Promise<PluginResult<ModifierGroup>> {
35
+ if (input.minSelect != null && input.maxSelect != null && input.minSelect > input.maxSelect) {
36
+ return Err("minSelect cannot exceed maxSelect");
37
+ }
38
+
39
+ const rows = await this.db
40
+ .insert(posModifierGroups)
41
+ .values({
42
+ organizationId: orgId,
43
+ name: input.name,
44
+ entityId: input.entityId,
45
+ itemGroup: input.itemGroup,
46
+ isRequired: input.isRequired ?? false,
47
+ minSelect: input.minSelect ?? 0,
48
+ maxSelect: input.maxSelect ?? 1,
49
+ sortOrder: input.sortOrder ?? 0,
50
+ })
51
+ .returning();
52
+
53
+ return Ok(rows[0]!);
54
+ }
55
+
56
+ async listGroups(orgId: string, entityId?: string): Promise<PluginResult<ModifierGroup[]>> {
57
+ const conditions = [eq(posModifierGroups.organizationId, orgId)];
58
+ if (entityId) conditions.push(eq(posModifierGroups.entityId, entityId));
59
+
60
+ const rows = await this.db
61
+ .select()
62
+ .from(posModifierGroups)
63
+ .where(and(...conditions))
64
+ .orderBy(posModifierGroups.sortOrder);
65
+
66
+ return Ok(rows);
67
+ }
68
+
69
+ async getGroupWithOptions(orgId: string, groupId: string): Promise<PluginResult<{
70
+ group: ModifierGroup;
71
+ options: ModifierOption[];
72
+ }>> {
73
+ const groups = await this.db
74
+ .select()
75
+ .from(posModifierGroups)
76
+ .where(and(eq(posModifierGroups.id, groupId), eq(posModifierGroups.organizationId, orgId)));
77
+
78
+ if (groups.length === 0) return Err("Modifier group not found");
79
+
80
+ const options = await this.db
81
+ .select()
82
+ .from(posModifierOptions)
83
+ .where(eq(posModifierOptions.groupId, groupId))
84
+ .orderBy(posModifierOptions.sortOrder);
85
+
86
+ return Ok({ group: groups[0]!, options });
87
+ }
88
+
89
+ async updateGroup(orgId: string, groupId: string, input: {
90
+ name?: string;
91
+ isRequired?: boolean;
92
+ minSelect?: number;
93
+ maxSelect?: number;
94
+ sortOrder?: number;
95
+ }): Promise<PluginResult<ModifierGroup>> {
96
+ const rows = await this.db
97
+ .update(posModifierGroups)
98
+ .set({ ...input, updatedAt: new Date() })
99
+ .where(and(eq(posModifierGroups.id, groupId), eq(posModifierGroups.organizationId, orgId)))
100
+ .returning();
101
+
102
+ if (rows.length === 0) return Err("Modifier group not found");
103
+ return Ok(rows[0]!);
104
+ }
105
+
106
+ async deleteGroup(orgId: string, groupId: string): Promise<PluginResult<{ deleted: boolean }>> {
107
+ const rows = await this.db
108
+ .delete(posModifierGroups)
109
+ .where(and(eq(posModifierGroups.id, groupId), eq(posModifierGroups.organizationId, orgId)))
110
+ .returning();
111
+
112
+ if (rows.length === 0) return Err("Modifier group not found");
113
+ return Ok({ deleted: true });
114
+ }
115
+
116
+ // ─── Option CRUD ───────────────────────────────────────────────────
117
+
118
+ async addOption(groupId: string, input: {
119
+ name: string;
120
+ priceAdjustment?: number;
121
+ isDefault?: boolean;
122
+ sortOrder?: number;
123
+ }): Promise<PluginResult<ModifierOption>> {
124
+ const rows = await this.db
125
+ .insert(posModifierOptions)
126
+ .values({
127
+ groupId,
128
+ name: input.name,
129
+ priceAdjustment: input.priceAdjustment ?? 0,
130
+ isDefault: input.isDefault ?? false,
131
+ sortOrder: input.sortOrder ?? 0,
132
+ })
133
+ .returning();
134
+
135
+ return Ok(rows[0]!);
136
+ }
137
+
138
+ async updateOption(optionId: string, input: {
139
+ name?: string;
140
+ priceAdjustment?: number;
141
+ isDefault?: boolean;
142
+ isAvailable?: boolean;
143
+ sortOrder?: number;
144
+ }): Promise<PluginResult<ModifierOption>> {
145
+ const rows = await this.db
146
+ .update(posModifierOptions)
147
+ .set(input)
148
+ .where(eq(posModifierOptions.id, optionId))
149
+ .returning();
150
+
151
+ if (rows.length === 0) return Err("Modifier option not found");
152
+ return Ok(rows[0]!);
153
+ }
154
+
155
+ async deleteOption(optionId: string): Promise<PluginResult<{ deleted: boolean }>> {
156
+ const rows = await this.db
157
+ .delete(posModifierOptions)
158
+ .where(eq(posModifierOptions.id, optionId))
159
+ .returning();
160
+
161
+ if (rows.length === 0) return Err("Modifier option not found");
162
+ return Ok({ deleted: true });
163
+ }
164
+
165
+ // ─── Validation ────────────────────────────────────────────────────
166
+ // Called by cart.beforeAddItem hook to validate modifier selections.
167
+
168
+ async validateModifiers(orgId: string, entityId: string, selections: Array<{
169
+ groupId: string;
170
+ optionIds: string[];
171
+ }>): Promise<PluginResult<{ totalAdjustment: number; validatedModifiers: Array<{ name: string; priceAdjustment: number }> }>> {
172
+ // Get all modifier groups for this entity
173
+ const groups = await this.db
174
+ .select()
175
+ .from(posModifierGroups)
176
+ .where(and(
177
+ eq(posModifierGroups.organizationId, orgId),
178
+ eq(posModifierGroups.entityId, entityId),
179
+ ));
180
+
181
+ const selectionMap = new Map(selections.map((s) => [s.groupId, s.optionIds]));
182
+ let totalAdjustment = 0;
183
+ const validatedModifiers: Array<{ name: string; priceAdjustment: number }> = [];
184
+
185
+ for (const group of groups) {
186
+ const selectedOptionIds = selectionMap.get(group.id) ?? [];
187
+
188
+ // Validate required groups
189
+ if (group.isRequired && selectedOptionIds.length < group.minSelect) {
190
+ return Err(`Required: select at least ${group.minSelect} from '${group.name}'`);
191
+ }
192
+
193
+ // Validate max selections
194
+ if (selectedOptionIds.length > group.maxSelect) {
195
+ return Err(`Maximum ${group.maxSelect} selections allowed for '${group.name}'`);
196
+ }
197
+
198
+ // Validate each selected option exists and is available
199
+ if (selectedOptionIds.length > 0) {
200
+ const options = await this.db
201
+ .select()
202
+ .from(posModifierOptions)
203
+ .where(eq(posModifierOptions.groupId, group.id));
204
+
205
+ const optionMap = new Map(options.map((o) => [o.id, o]));
206
+
207
+ for (const optionId of selectedOptionIds) {
208
+ const option = optionMap.get(optionId);
209
+ if (!option) return Err(`Modifier option '${optionId}' not found in group '${group.name}'`);
210
+ if (!option.isAvailable) return Err(`Modifier '${option.name}' is currently unavailable`);
211
+
212
+ totalAdjustment += option.priceAdjustment;
213
+ validatedModifiers.push({ name: option.name, priceAdjustment: option.priceAdjustment });
214
+ }
215
+ }
216
+ }
217
+
218
+ return Ok({ totalAdjustment, validatedModifiers });
219
+ }
220
+
221
+ // ─── Get Modifiers for Entity ──────────────────────────────────────
222
+ // Returns all modifier groups with their options for a given entity.
223
+ // Used by the POS frontend to render modifier selection UI.
224
+
225
+ async getModifiersForEntity(orgId: string, entityId: string): Promise<PluginResult<Array<{
226
+ group: ModifierGroup;
227
+ options: ModifierOption[];
228
+ }>>> {
229
+ const groups = await this.db
230
+ .select()
231
+ .from(posModifierGroups)
232
+ .where(and(
233
+ eq(posModifierGroups.organizationId, orgId),
234
+ eq(posModifierGroups.entityId, entityId),
235
+ ))
236
+ .orderBy(posModifierGroups.sortOrder);
237
+
238
+ const result = [];
239
+ for (const group of groups) {
240
+ const options = await this.db
241
+ .select()
242
+ .from(posModifierOptions)
243
+ .where(eq(posModifierOptions.groupId, group.id))
244
+ .orderBy(posModifierOptions.sortOrder);
245
+
246
+ result.push({ group, options });
247
+ }
248
+
249
+ return Ok(result);
250
+ }
251
+ }