@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
package/src/schema.ts ADDED
@@ -0,0 +1,462 @@
1
+ /**
2
+ * POS Restaurant Extension Schema — RFC-024a
3
+ *
4
+ * 8 tables extending the POS Tier 0 plugin for restaurant operations:
5
+ *
6
+ * Modifiers:
7
+ * - pos_modifier_groups: modifier group definitions (required/optional, min/max)
8
+ * - pos_modifier_options: individual options within a group (name, price adjustment)
9
+ *
10
+ * Tables:
11
+ * - pos_tables: physical table registry (zone, capacity, shape, floor plan layout)
12
+ * - pos_table_assignments: links tables to active POS transactions
13
+ *
14
+ * KDS (Kitchen Display System):
15
+ * - kds_stations: kitchen section definitions (item group routing)
16
+ * - kds_station_item_groups: maps item groups to stations for ticket routing
17
+ * - kds_tickets: kitchen tickets routed to stations (status: pending -> preparing -> ready -> served)
18
+ * - kds_ticket_items: individual items within a ticket (course priority, modifiers, item-level status)
19
+ *
20
+ * Informed by URY Restaurant ERP production patterns:
21
+ * - URY Table (room-based, occupied flag, floor plan layout_x/y, shape, is_take_away)
22
+ * - URY Production Unit (item-group routing, per-station printers)
23
+ * - URY KOT (type enum, order_status, production_time, course serving priority)
24
+ * - URY Menu Course (custom_serving_priority, custom_indicate_in_kds)
25
+ */
26
+
27
+ import { pgTable, uuid, text, integer, boolean, timestamp, jsonb, index, uniqueIndex } from "@porulle/core/drizzle";
28
+
29
+ // ─── Modifier Groups ───────────────────────────────────────────────────
30
+ // URY equivalent: Item Add On (flat item links) — we add structured grouping,
31
+ // required/optional, min/max constraints, and price adjustments.
32
+
33
+ export const posModifierGroups = pgTable("pos_modifier_groups", {
34
+ id: uuid("id").defaultRandom().primaryKey(),
35
+ organizationId: text("organization_id").notNull(),
36
+ name: text("name").notNull(),
37
+ entityId: uuid("entity_id"),
38
+ itemGroup: text("item_group"),
39
+ isRequired: boolean("is_required").notNull().default(false),
40
+ minSelect: integer("min_select").notNull().default(0),
41
+ maxSelect: integer("max_select").notNull().default(1),
42
+ sortOrder: integer("sort_order").notNull().default(0),
43
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
44
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
45
+ }, (table) => ({
46
+ orgIdx: index("idx_pos_modifier_groups_org").on(table.organizationId),
47
+ entityIdx: index("idx_pos_modifier_groups_entity").on(table.entityId),
48
+ nameUnique: uniqueIndex("pos_modifier_groups_org_name_entity_unique")
49
+ .on(table.organizationId, table.name, table.entityId),
50
+ }));
51
+
52
+ // ─── Modifier Options ──────────────────────────────────────────────────
53
+ // Individual choices within a modifier group. Each option can carry a price
54
+ // adjustment (surcharge or discount). URY had no pricing on modifiers.
55
+
56
+ export const posModifierOptions = pgTable("pos_modifier_options", {
57
+ id: uuid("id").defaultRandom().primaryKey(),
58
+ groupId: uuid("group_id").references(() => posModifierGroups.id, { onDelete: "cascade" }).notNull(),
59
+ name: text("name").notNull(),
60
+ priceAdjustment: integer("price_adjustment").notNull().default(0),
61
+ isDefault: boolean("is_default").notNull().default(false),
62
+ isAvailable: boolean("is_available").notNull().default(true),
63
+ entityId: uuid("entity_id"), // optional: links to inventory entity for deduction
64
+ inventoryQuantity: integer("inventory_quantity").notNull().default(0), // amount to deduct per application
65
+ sortOrder: integer("sort_order").notNull().default(0),
66
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
67
+ }, (table) => ({
68
+ groupIdx: index("idx_pos_modifier_options_group").on(table.groupId),
69
+ }));
70
+
71
+ // ─── Tables ─────────────────────────────────────────────────────────────
72
+ // URY equivalent: URY Table with restaurant_room, no_of_seats, table_shape,
73
+ // layout_x/y, is_take_away, occupied (binary). We improve with a 4-state
74
+ // status machine and assignedOperatorId for server sections.
75
+
76
+ export const posTables = pgTable("pos_tables", {
77
+ id: uuid("id").defaultRandom().primaryKey(),
78
+ organizationId: text("organization_id").notNull(),
79
+ number: text("number").notNull(),
80
+ zone: text("zone").notNull(),
81
+ capacity: integer("capacity").notNull().default(4),
82
+ minimumSeats: integer("minimum_seats").notNull().default(1),
83
+ shape: text("shape", { enum: ["rectangle", "square", "circle"] }).notNull().default("rectangle"),
84
+ status: text("status", { enum: ["available", "occupied", "bill_requested", "cleaning"] }).notNull().default("available"),
85
+ isTakeaway: boolean("is_takeaway").notNull().default(false),
86
+ assignedOperatorId: text("assigned_operator_id"),
87
+ layoutX: integer("layout_x").notNull().default(0),
88
+ layoutY: integer("layout_y").notNull().default(0),
89
+ layoutWidth: integer("layout_width").notNull().default(100),
90
+ layoutHeight: integer("layout_height").notNull().default(100),
91
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
92
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
93
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
94
+ }, (table) => ({
95
+ orgIdx: index("idx_pos_tables_org").on(table.organizationId),
96
+ numberUnique: uniqueIndex("pos_tables_org_number_unique").on(table.organizationId, table.number),
97
+ zoneIdx: index("idx_pos_tables_zone").on(table.zone),
98
+ statusIdx: index("idx_pos_tables_status").on(table.status),
99
+ }));
100
+
101
+ // ─── Table Assignments ──────────────────────────────────────────────────
102
+ // Links tables to active POS transactions. Supports multi-table seating
103
+ // (large party across 2+ tables). URY stored this as a single FK on POS Invoice.
104
+
105
+ export const posTableAssignments = pgTable("pos_table_assignments", {
106
+ id: uuid("id").defaultRandom().primaryKey(),
107
+ tableId: uuid("table_id").references(() => posTables.id, { onDelete: "cascade" }).notNull(),
108
+ transactionId: uuid("transaction_id").notNull(),
109
+ seatedAt: timestamp("seated_at", { withTimezone: true }).defaultNow().notNull(),
110
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
111
+ }, (table) => ({
112
+ tableIdx: index("idx_pos_table_assignments_table").on(table.tableId),
113
+ transactionIdx: index("idx_pos_table_assignments_transaction").on(table.transactionId),
114
+ }));
115
+
116
+ // ─── KDS Stations ───────────────────────────────────────────────────────
117
+ // URY equivalent: URY Production Unit. Represents a kitchen section/station
118
+ // that receives tickets for items matching its assigned item groups.
119
+
120
+ export const kdsStations = pgTable("kds_stations", {
121
+ id: uuid("id").defaultRandom().primaryKey(),
122
+ organizationId: text("organization_id").notNull(),
123
+ name: text("name").notNull(),
124
+ isActive: boolean("is_active").notNull().default(true),
125
+ alertThresholdMinutes: integer("alert_threshold_minutes").notNull().default(15),
126
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
127
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
128
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
129
+ }, (table) => ({
130
+ orgIdx: index("idx_kds_stations_org").on(table.organizationId),
131
+ nameUnique: uniqueIndex("kds_stations_org_name_unique").on(table.organizationId, table.name),
132
+ }));
133
+
134
+ // ─── KDS Station Item Groups ────────────────────────────────────────────
135
+ // URY equivalent: URY Production Item Groups. Maps item categories to
136
+ // stations for routing. When a POS transaction adds a "mains" item,
137
+ // the system routes it to the station that has "mains" in its item groups.
138
+
139
+ export const kdsStationItemGroups = pgTable("kds_station_item_groups", {
140
+ id: uuid("id").defaultRandom().primaryKey(),
141
+ stationId: uuid("station_id").references(() => kdsStations.id, { onDelete: "cascade" }).notNull(),
142
+ itemGroup: text("item_group").notNull(),
143
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
144
+ }, (table) => ({
145
+ stationIdx: index("idx_kds_station_item_groups_station").on(table.stationId),
146
+ }));
147
+
148
+ // ─── KDS Tickets ────────────────────────────────────────────────────────
149
+ // URY equivalent: URY KOT. A kitchen ticket routed to a specific station.
150
+ // One POS transaction may generate multiple tickets (one per station with
151
+ // matching items). URY's order_status was "Ready For Prepare" -> "Served";
152
+ // we add "preparing" and "ready" for finer-grained kitchen tracking.
153
+
154
+ export const kdsTickets = pgTable("kds_tickets", {
155
+ id: uuid("id").defaultRandom().primaryKey(),
156
+ organizationId: text("organization_id").notNull(),
157
+ transactionId: uuid("transaction_id").notNull(),
158
+ stationId: uuid("station_id").references(() => kdsStations.id).notNull(),
159
+ orderId: uuid("order_id"),
160
+ type: text("type", { enum: ["new_order", "modified", "cancelled", "partially_cancelled"] }).notNull().default("new_order"),
161
+ status: text("status", { enum: ["pending", "preparing", "ready", "served"] }).notNull().default("pending"),
162
+ tableNumber: text("table_number"),
163
+ orderType: text("order_type", { enum: ["dine_in", "takeaway", "delivery"] }).notNull().default("dine_in"),
164
+ operatorName: text("operator_name"),
165
+ ticketNumber: text("ticket_number").notNull(),
166
+ firedAt: timestamp("fired_at", { withTimezone: true }),
167
+ readyAt: timestamp("ready_at", { withTimezone: true }),
168
+ servedAt: timestamp("served_at", { withTimezone: true }),
169
+ prepDurationSeconds: integer("prep_duration_seconds"),
170
+ comments: text("comments"),
171
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
172
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
173
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
174
+ }, (table) => ({
175
+ orgIdx: index("idx_kds_tickets_org").on(table.organizationId),
176
+ stationStatusIdx: index("idx_kds_tickets_station_status").on(table.stationId, table.status),
177
+ transactionIdx: index("idx_kds_tickets_transaction").on(table.transactionId),
178
+ }));
179
+
180
+ // ─── KDS Ticket Items ───────────────────────────────────────────────────
181
+ // URY equivalent: URY KOT Items. Individual items within a ticket.
182
+ // URY stored item strikethrough in browser localStorage only — we persist
183
+ // item-level status to the database. Course priority (URY: custom_serving_priority)
184
+ // and course label display (URY: custom_indicate_in_kds) are first-class fields.
185
+
186
+ export const kdsTicketItems = pgTable("kds_ticket_items", {
187
+ id: uuid("id").defaultRandom().primaryKey(),
188
+ ticketId: uuid("ticket_id").references(() => kdsTickets.id, { onDelete: "cascade" }).notNull(),
189
+ entityId: uuid("entity_id").notNull(),
190
+ variantId: uuid("variant_id"),
191
+ itemName: text("item_name").notNull(),
192
+ quantity: integer("quantity").notNull(),
193
+ cancelledQuantity: integer("cancelled_quantity").notNull().default(0),
194
+ courseName: text("course_name"),
195
+ coursePriority: integer("course_priority").notNull().default(0),
196
+ showCourseLabel: boolean("show_course_label").notNull().default(false),
197
+ status: text("status", { enum: ["pending", "preparing", "done"] }).notNull().default("pending"),
198
+ modifiers: jsonb("modifiers").$type<Array<{ name: string; priceAdjustment: number }>>().default([]),
199
+ notes: text("notes"),
200
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
201
+ }, (table) => ({
202
+ ticketIdx: index("idx_kds_ticket_items_ticket").on(table.ticketId),
203
+ }));
204
+
205
+ // ═══════════════════════════════════════════════════════════════════════
206
+ // OPERATIONAL FEATURES — Checklists, Alerts, Menu Availability
207
+ // ═══════════════════════════════════════════════════════════════════════
208
+
209
+ // ─── Pre-Billing Checklists ─────────────────────────────────────────────
210
+ // URY: Pre-billing checklists to enforce compliance (stock check, hygiene).
211
+ // Configurable checklists that must be completed before a bill can be printed.
212
+
213
+ export const posChecklists = pgTable("pos_checklists", {
214
+ id: uuid("id").defaultRandom().primaryKey(),
215
+ organizationId: text("organization_id").notNull(),
216
+ name: text("name").notNull(),
217
+ type: text("type", { enum: ["pre_billing", "shift_open", "shift_close"] }).notNull(),
218
+ isActive: boolean("is_active").notNull().default(true),
219
+ sortOrder: integer("sort_order").notNull().default(0),
220
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
221
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
222
+ }, (table) => ({
223
+ orgIdx: index("idx_pos_checklists_org").on(table.organizationId),
224
+ }));
225
+
226
+ export const posChecklistItems = pgTable("pos_checklist_items", {
227
+ id: uuid("id").defaultRandom().primaryKey(),
228
+ checklistId: uuid("checklist_id").references(() => posChecklists.id, { onDelete: "cascade" }).notNull(),
229
+ label: text("label").notNull(),
230
+ isRequired: boolean("is_required").notNull().default(true),
231
+ sortOrder: integer("sort_order").notNull().default(0),
232
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
233
+ }, (table) => ({
234
+ checklistIdx: index("idx_pos_checklist_items_checklist").on(table.checklistId),
235
+ }));
236
+
237
+ // ─── Checklist Completions ──────────────────────────────────────────────
238
+ // Records when an operator completes a checklist for a transaction or shift.
239
+
240
+ export const posChecklistCompletions = pgTable("pos_checklist_completions", {
241
+ id: uuid("id").defaultRandom().primaryKey(),
242
+ checklistId: uuid("checklist_id").references(() => posChecklists.id).notNull(),
243
+ referenceType: text("reference_type", { enum: ["transaction", "shift"] }).notNull(),
244
+ referenceId: uuid("reference_id").notNull(),
245
+ operatorId: text("operator_id").notNull(),
246
+ completedItems: jsonb("completed_items").$type<Array<{ itemId: string; checked: boolean; note?: string }>>().notNull(),
247
+ completedAt: timestamp("completed_at", { withTimezone: true }).defaultNow().notNull(),
248
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
249
+ }, (table) => ({
250
+ referenceIdx: index("idx_pos_checklist_completions_ref").on(table.referenceType, table.referenceId),
251
+ }));
252
+
253
+ // ─── Operational Alerts ─────────────────────────────────────────────────
254
+ // URY: Red flags for delayed orders, unclosed bills, excessive cancellations,
255
+ // prolonged table occupancy. Real-time alerts for operational exceptions.
256
+
257
+ export const posRestaurantAlerts = pgTable("pos_restaurant_alerts", {
258
+ id: uuid("id").defaultRandom().primaryKey(),
259
+ organizationId: text("organization_id").notNull(),
260
+ type: text("type", {
261
+ enum: [
262
+ "delayed_order",
263
+ "kot_not_started",
264
+ "unclosed_bill",
265
+ "prolonged_occupancy",
266
+ "excessive_cancellations",
267
+ "excessive_modifications",
268
+ ],
269
+ }).notNull(),
270
+ severity: text("severity", { enum: ["warning", "critical"] }).notNull().default("warning"),
271
+ referenceType: text("reference_type").notNull(),
272
+ referenceId: text("reference_id").notNull(),
273
+ message: text("message").notNull(),
274
+ isResolved: boolean("is_resolved").notNull().default(false),
275
+ resolvedBy: text("resolved_by"),
276
+ resolvedAt: timestamp("resolved_at", { withTimezone: true }),
277
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
278
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
279
+ }, (table) => ({
280
+ orgIdx: index("idx_pos_restaurant_alerts_org").on(table.organizationId),
281
+ typeIdx: index("idx_pos_restaurant_alerts_type").on(table.type),
282
+ unresolvedIdx: index("idx_pos_restaurant_alerts_unresolved").on(table.organizationId, table.isResolved),
283
+ }));
284
+
285
+ // ─── Alert Configuration ────────────────────────────────────────────────
286
+ // Thresholds for each alert type per organization.
287
+
288
+ export const posAlertConfig = pgTable("pos_alert_config", {
289
+ id: uuid("id").defaultRandom().primaryKey(),
290
+ organizationId: text("organization_id").notNull(),
291
+ alertType: text("alert_type").notNull(),
292
+ thresholdMinutes: integer("threshold_minutes").notNull(),
293
+ isEnabled: boolean("is_enabled").notNull().default(true),
294
+ notifyRoles: jsonb("notify_roles").$type<string[]>().default([]),
295
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
296
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
297
+ }, (table) => ({
298
+ orgTypeUnique: uniqueIndex("pos_alert_config_org_type_unique").on(table.organizationId, table.alertType),
299
+ }));
300
+
301
+ // ═══════════════════════════════════════════════════════════════════════
302
+ // MENU & RECIPE MANAGEMENT — Combos, BOM, Availability
303
+ // ═══════════════════════════════════════════════════════════════════════
304
+
305
+ // ─── Recipes (Bill of Materials) ────────────────────────────────────────
306
+ // URY: Recipe mapping using BOM. Links menu items to raw ingredients
307
+ // for COGS calculation in P&L.
308
+
309
+ export const posRecipes = pgTable("pos_recipes", {
310
+ id: uuid("id").defaultRandom().primaryKey(),
311
+ organizationId: text("organization_id").notNull(),
312
+ entityId: uuid("entity_id").notNull(),
313
+ name: text("name").notNull(),
314
+ yieldQuantity: integer("yield_quantity").notNull().default(1),
315
+ isActive: boolean("is_active").notNull().default(true),
316
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
317
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
318
+ }, (table) => ({
319
+ orgIdx: index("idx_pos_recipes_org").on(table.organizationId),
320
+ entityIdx: index("idx_pos_recipes_entity").on(table.entityId),
321
+ }));
322
+
323
+ export const posRecipeIngredients = pgTable("pos_recipe_ingredients", {
324
+ id: uuid("id").defaultRandom().primaryKey(),
325
+ recipeId: uuid("recipe_id").references(() => posRecipes.id, { onDelete: "cascade" }).notNull(),
326
+ ingredientName: text("ingredient_name").notNull(),
327
+ quantity: integer("quantity").notNull(),
328
+ unit: text("unit").notNull().default("g"),
329
+ costPerUnit: integer("cost_per_unit").notNull().default(0),
330
+ entityId: uuid("entity_id"), // optional: links ingredient to inventory entity for deduction
331
+ variantId: uuid("variant_id"), // optional: specific variant to deduct
332
+ sortOrder: integer("sort_order").notNull().default(0),
333
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
334
+ }, (table) => ({
335
+ recipeIdx: index("idx_pos_recipe_ingredients_recipe").on(table.recipeId),
336
+ }));
337
+
338
+ // ─── Combos / Meal Deals ────────────────────────────────────────────────
339
+ // URY: Supports combos, modifiers, and item bundles.
340
+ // A combo has groups (e.g., "Choose your drink", "Choose your side")
341
+ // and a fixed bundle price.
342
+
343
+ export const posCombos = pgTable("pos_combos", {
344
+ id: uuid("id").defaultRandom().primaryKey(),
345
+ organizationId: text("organization_id").notNull(),
346
+ name: text("name").notNull(),
347
+ entityId: uuid("entity_id").notNull(),
348
+ price: integer("price").notNull(),
349
+ isActive: boolean("is_active").notNull().default(true),
350
+ sortOrder: integer("sort_order").notNull().default(0),
351
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
352
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
353
+ }, (table) => ({
354
+ orgIdx: index("idx_pos_combos_org").on(table.organizationId),
355
+ entityIdx: index("idx_pos_combos_entity").on(table.entityId),
356
+ }));
357
+
358
+ export const posComboGroups = pgTable("pos_combo_groups", {
359
+ id: uuid("id").defaultRandom().primaryKey(),
360
+ comboId: uuid("combo_id").references(() => posCombos.id, { onDelete: "cascade" }).notNull(),
361
+ name: text("name").notNull(),
362
+ minSelect: integer("min_select").notNull().default(1),
363
+ maxSelect: integer("max_select").notNull().default(1),
364
+ sortOrder: integer("sort_order").notNull().default(0),
365
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
366
+ }, (table) => ({
367
+ comboIdx: index("idx_pos_combo_groups_combo").on(table.comboId),
368
+ }));
369
+
370
+ export const posComboItems = pgTable("pos_combo_items", {
371
+ id: uuid("id").defaultRandom().primaryKey(),
372
+ groupId: uuid("group_id").references(() => posComboGroups.id, { onDelete: "cascade" }).notNull(),
373
+ entityId: uuid("entity_id").notNull(),
374
+ itemName: text("item_name").notNull(),
375
+ priceAdjustment: integer("price_adjustment").notNull().default(0),
376
+ isDefault: boolean("is_default").notNull().default(false),
377
+ sortOrder: integer("sort_order").notNull().default(0),
378
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
379
+ }, (table) => ({
380
+ groupIdx: index("idx_pos_combo_items_group").on(table.groupId),
381
+ }));
382
+
383
+ // ─── Menu Availability ──────────────────────────────────────────────────
384
+ // URY: Control pricing, availability, and portions per outlet.
385
+ // URY Menu Item has a `disabled` checkbox per item.
386
+
387
+ export const posMenuAvailability = pgTable("pos_menu_availability", {
388
+ id: uuid("id").defaultRandom().primaryKey(),
389
+ organizationId: text("organization_id").notNull(),
390
+ entityId: uuid("entity_id").notNull(),
391
+ isAvailable: boolean("is_available").notNull().default(true),
392
+ unavailableReason: text("unavailable_reason"),
393
+ unavailableSince: timestamp("unavailable_since", { withTimezone: true }),
394
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
395
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
396
+ }, (table) => ({
397
+ orgEntityUnique: uniqueIndex("pos_menu_availability_org_entity_unique").on(table.organizationId, table.entityId),
398
+ }));
399
+
400
+ // ═══════════════════════════════════════════════════════════════════════
401
+ // ANALYTICS — Daily P&L, Performance Tracking
402
+ // ═══════════════════════════════════════════════════════════════════════
403
+
404
+ // ─── Daily Profit & Loss ────────────────────────────────────────────────
405
+ // URY: URY Daily P&L doctype. Calculates gross sales, COGS, direct expenses,
406
+ // indirect expenses, employee costs, and net profit for each day.
407
+
408
+ export const posDailyPnl = pgTable("pos_daily_pnl", {
409
+ id: uuid("id").defaultRandom().primaryKey(),
410
+ organizationId: text("organization_id").notNull(),
411
+ date: timestamp("date", { withTimezone: true }).notNull(),
412
+ grossSales: integer("gross_sales").notNull().default(0),
413
+ netSales: integer("net_sales").notNull().default(0),
414
+ costOfGoods: integer("cost_of_goods").notNull().default(0),
415
+ directExpenses: integer("direct_expenses").notNull().default(0),
416
+ indirectExpenses: integer("indirect_expenses").notNull().default(0),
417
+ employeeCosts: integer("employee_costs").notNull().default(0),
418
+ grossProfit: integer("gross_profit").notNull().default(0),
419
+ netProfit: integer("net_profit").notNull().default(0),
420
+ transactionCount: integer("transaction_count").notNull().default(0),
421
+ averageBillValue: integer("average_bill_value").notNull().default(0),
422
+ status: text("status", { enum: ["draft", "submitted"] }).notNull().default("draft"),
423
+ metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
424
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
425
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
426
+ }, (table) => ({
427
+ orgDateUnique: uniqueIndex("pos_daily_pnl_org_date_unique").on(table.organizationId, table.date),
428
+ }));
429
+
430
+ // ─── P&L Expense Line Items ─────────────────────────────────────────────
431
+ // Breakdown of expenses for a daily P&L record.
432
+
433
+ export const posPnlExpenses = pgTable("pos_pnl_expenses", {
434
+ id: uuid("id").defaultRandom().primaryKey(),
435
+ pnlId: uuid("pnl_id").references(() => posDailyPnl.id, { onDelete: "cascade" }).notNull(),
436
+ category: text("category", { enum: ["cogs", "direct", "indirect", "employee"] }).notNull(),
437
+ name: text("name").notNull(),
438
+ amount: integer("amount").notNull(),
439
+ percentage: integer("percentage"),
440
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
441
+ }, (table) => ({
442
+ pnlIdx: index("idx_pos_pnl_expenses_pnl").on(table.pnlId),
443
+ }));
444
+
445
+ // ─── Customer Favorites ─────────────────────────────────────────────────
446
+ // URY: For returning customers, displays their top 3 ordered items.
447
+ // Materialized view of customer order history for fast POS lookup.
448
+
449
+ export const posCustomerFavorites = pgTable("pos_customer_favorites", {
450
+ id: uuid("id").defaultRandom().primaryKey(),
451
+ organizationId: text("organization_id").notNull(),
452
+ customerId: uuid("customer_id").notNull(),
453
+ entityId: uuid("entity_id").notNull(),
454
+ itemName: text("item_name").notNull(),
455
+ orderCount: integer("order_count").notNull().default(1),
456
+ lastOrderedAt: timestamp("last_ordered_at", { withTimezone: true }).defaultNow().notNull(),
457
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
458
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
459
+ }, (table) => ({
460
+ customerIdx: index("idx_pos_customer_favorites_customer").on(table.organizationId, table.customerId),
461
+ orgCustomerEntityUnique: uniqueIndex("pos_customer_favorites_unique").on(table.organizationId, table.customerId, table.entityId),
462
+ }));