@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.
- package/README.md +61 -0
- package/dist/hooks/modifier-validation.d.ts +21 -0
- package/dist/hooks/modifier-validation.d.ts.map +1 -0
- package/dist/hooks/modifier-validation.js +40 -0
- package/dist/hooks/table-lifecycle.d.ts +15 -0
- package/dist/hooks/table-lifecycle.d.ts.map +1 -0
- package/dist/hooks/table-lifecycle.js +40 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +169 -0
- package/dist/routes/kds.d.ts +9 -0
- package/dist/routes/kds.d.ts.map +1 -0
- package/dist/routes/kds.js +112 -0
- package/dist/routes/modifiers.d.ts +15 -0
- package/dist/routes/modifiers.d.ts.map +1 -0
- package/dist/routes/modifiers.js +117 -0
- package/dist/routes/operations.d.ts +34 -0
- package/dist/routes/operations.d.ts.map +1 -0
- package/dist/routes/operations.js +249 -0
- package/dist/routes/tables.d.ts +9 -0
- package/dist/routes/tables.d.ts.map +1 -0
- package/dist/routes/tables.js +110 -0
- package/dist/schema.d.ts +3831 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +418 -0
- package/dist/services/alert-service.d.ts +56 -0
- package/dist/services/alert-service.d.ts.map +1 -0
- package/dist/services/alert-service.js +173 -0
- package/dist/services/analytics-service.d.ts +60 -0
- package/dist/services/analytics-service.d.ts.map +1 -0
- package/dist/services/analytics-service.js +178 -0
- package/dist/services/checklist-service.d.ts +66 -0
- package/dist/services/checklist-service.d.ts.map +1 -0
- package/dist/services/checklist-service.js +106 -0
- package/dist/services/kds-service.d.ts +77 -0
- package/dist/services/kds-service.d.ts.map +1 -0
- package/dist/services/kds-service.js +246 -0
- package/dist/services/modifier-service.d.ts +74 -0
- package/dist/services/modifier-service.d.ts.map +1 -0
- package/dist/services/modifier-service.js +180 -0
- package/dist/services/recipe-deduction-service.d.ts +64 -0
- package/dist/services/recipe-deduction-service.d.ts.map +1 -0
- package/dist/services/recipe-deduction-service.js +144 -0
- package/dist/services/recipe-service.d.ts +43 -0
- package/dist/services/recipe-service.d.ts.map +1 -0
- package/dist/services/recipe-service.js +85 -0
- package/dist/services/table-service.d.ts +61 -0
- package/dist/services/table-service.d.ts.map +1 -0
- package/dist/services/table-service.js +206 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/package.json +61 -0
- package/src/hooks/modifier-validation.ts +60 -0
- package/src/hooks/table-lifecycle.ts +50 -0
- package/src/index.ts +218 -0
- package/src/routes/kds.ts +121 -0
- package/src/routes/modifiers.ts +129 -0
- package/src/routes/operations.ts +276 -0
- package/src/routes/tables.ts +117 -0
- package/src/schema.ts +462 -0
- package/src/services/alert-service.ts +234 -0
- package/src/services/analytics-service.ts +242 -0
- package/src/services/checklist-service.ts +139 -0
- package/src/services/kds-service.ts +340 -0
- package/src/services/modifier-service.ts +251 -0
- package/src/services/recipe-deduction-service.ts +221 -0
- package/src/services/recipe-service.ts +115 -0
- package/src/services/table-service.ts +260 -0
- 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
|
+
}));
|