skytab_sandbox_simulator 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.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/bin/simulate +433 -0
- data/lib/skytab_sandbox_simulator/configuration.rb +205 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/items.json +28 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/tenders.json +19 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/items.json +30 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/tenders.json +17 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/items.json +30 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/tenders.json +18 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/items.json +28 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/tenders.json +18 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/categories.json +44 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/items.json +59 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/tenders.json +22 -0
- data/lib/skytab_sandbox_simulator/database.rb +192 -0
- data/lib/skytab_sandbox_simulator/db/factories/business_types.rb +102 -0
- data/lib/skytab_sandbox_simulator/db/factories/categories.rb +243 -0
- data/lib/skytab_sandbox_simulator/db/factories/items.rb +976 -0
- data/lib/skytab_sandbox_simulator/db/factories/simulated_orders.rb +120 -0
- data/lib/skytab_sandbox_simulator/db/factories/simulated_payments.rb +75 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +35 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
- data/lib/skytab_sandbox_simulator/generators/data_loader.rb +125 -0
- data/lib/skytab_sandbox_simulator/generators/entity_generator.rb +107 -0
- data/lib/skytab_sandbox_simulator/generators/order_generator.rb +390 -0
- data/lib/skytab_sandbox_simulator/models/api_request.rb +43 -0
- data/lib/skytab_sandbox_simulator/models/business_type.rb +25 -0
- data/lib/skytab_sandbox_simulator/models/category.rb +17 -0
- data/lib/skytab_sandbox_simulator/models/daily_summary.rb +67 -0
- data/lib/skytab_sandbox_simulator/models/item.rb +32 -0
- data/lib/skytab_sandbox_simulator/models/record.rb +14 -0
- data/lib/skytab_sandbox_simulator/models/simulated_order.rb +40 -0
- data/lib/skytab_sandbox_simulator/models/simulated_payment.rb +28 -0
- data/lib/skytab_sandbox_simulator/seeder.rb +167 -0
- data/lib/skytab_sandbox_simulator/services/base_service.rb +227 -0
- data/lib/skytab_sandbox_simulator/services/skytab/catalog_service.rb +130 -0
- data/lib/skytab_sandbox_simulator/services/skytab/location_service.rb +54 -0
- data/lib/skytab_sandbox_simulator/services/skytab/order_service.rb +139 -0
- data/lib/skytab_sandbox_simulator/services/skytab/payment_service.rb +94 -0
- data/lib/skytab_sandbox_simulator/services/skytab/service_manager.rb +62 -0
- data/lib/skytab_sandbox_simulator/version.rb +5 -0
- data/lib/skytab_sandbox_simulator.rb +45 -0
- metadata +305 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Generators
|
|
5
|
+
# Generates realistic SkyTab orders (tickets) and payments
|
|
6
|
+
class OrderGenerator
|
|
7
|
+
# Meal periods with realistic distributions
|
|
8
|
+
MEAL_PERIODS = {
|
|
9
|
+
breakfast: { hours: 7..10, weight: 15, avg_items: 2..4, avg_party: 1..3 },
|
|
10
|
+
lunch: { hours: 11..14, weight: 30, avg_items: 3..6, avg_party: 1..4 },
|
|
11
|
+
happy_hour: { hours: 15..17, weight: 10, avg_items: 2..5, avg_party: 2..5 },
|
|
12
|
+
dinner: { hours: 17..21, weight: 35, avg_items: 3..8, avg_party: 2..6 },
|
|
13
|
+
late_night: { hours: 21..23, weight: 10, avg_items: 2..5, avg_party: 1..3 }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Revenue class distributions by meal period
|
|
17
|
+
REVENUE_CLASS_BY_PERIOD = {
|
|
18
|
+
breakfast: { "DINE_IN" => 40, "TAKEOUT" => 50, "DELIVERY" => 10 },
|
|
19
|
+
lunch: { "DINE_IN" => 35, "TAKEOUT" => 45, "DELIVERY" => 20 },
|
|
20
|
+
happy_hour: { "DINE_IN" => 60, "BAR" => 30, "TAKEOUT" => 10 },
|
|
21
|
+
dinner: { "DINE_IN" => 65, "BAR" => 10, "TAKEOUT" => 10, "DELIVERY" => 15 },
|
|
22
|
+
late_night: { "BAR" => 40, "DINE_IN" => 30, "TAKEOUT" => 20, "DELIVERY" => 10 }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Tip percentages by revenue class
|
|
26
|
+
TIP_RATES = {
|
|
27
|
+
"DINE_IN" => { min: 15, max: 25 },
|
|
28
|
+
"TAKEOUT" => { min: 0, max: 15 },
|
|
29
|
+
"DELIVERY" => { min: 10, max: 20 },
|
|
30
|
+
"BAR" => { min: 15, max: 25 },
|
|
31
|
+
"CATERING" => { min: 18, max: 22 }
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# Order patterns by day of week
|
|
35
|
+
ORDER_PATTERNS = {
|
|
36
|
+
weekday: { min: 40, max: 60 },
|
|
37
|
+
friday: { min: 70, max: 100 },
|
|
38
|
+
saturday: { min: 80, max: 120 },
|
|
39
|
+
sunday: { min: 50, max: 80 }
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Tender distribution weights
|
|
43
|
+
TENDER_WEIGHTS = {
|
|
44
|
+
"CASH" => 15,
|
|
45
|
+
"VISA" => 30,
|
|
46
|
+
"MASTERCARD" => 20,
|
|
47
|
+
"AMEX" => 10,
|
|
48
|
+
"DISCOVER" => 5,
|
|
49
|
+
"GIFT_CARD" => 10,
|
|
50
|
+
"HOUSE_ACCOUNT" => 10
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Tax rates (divide by 10,000 for percentage)
|
|
54
|
+
SALES_TAX_RATE = 82_500 # 8.25%
|
|
55
|
+
ALCOHOL_TAX_RATE = 100_000 # 10.0%
|
|
56
|
+
|
|
57
|
+
attr_reader :services, :logger, :stats, :refund_percentage
|
|
58
|
+
|
|
59
|
+
def initialize(services: nil, refund_percentage: 5)
|
|
60
|
+
@services = services || Services::Skytab::ServiceManager.new
|
|
61
|
+
@logger = SkytabSandboxSimulator.logger
|
|
62
|
+
@refund_percentage = refund_percentage
|
|
63
|
+
@stats = {
|
|
64
|
+
orders: 0,
|
|
65
|
+
revenue: 0,
|
|
66
|
+
tips: 0,
|
|
67
|
+
tax: 0,
|
|
68
|
+
by_period: Hash.new(0),
|
|
69
|
+
by_revenue_class: Hash.new(0),
|
|
70
|
+
by_tender: Hash.new(0)
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Generate orders for today
|
|
75
|
+
#
|
|
76
|
+
# @param count [Integer, nil] Number of orders (nil for automatic based on day)
|
|
77
|
+
# @return [Array<Hash>] Generated orders
|
|
78
|
+
def generate_today(count: nil)
|
|
79
|
+
count ||= determine_order_count
|
|
80
|
+
logger.info "Generating #{count} orders for today..."
|
|
81
|
+
|
|
82
|
+
data = fetch_required_data
|
|
83
|
+
return [] unless data
|
|
84
|
+
|
|
85
|
+
orders = []
|
|
86
|
+
count.times do |i|
|
|
87
|
+
period = select_meal_period
|
|
88
|
+
order = create_realistic_order(
|
|
89
|
+
period: period,
|
|
90
|
+
data: data,
|
|
91
|
+
order_num: i + 1,
|
|
92
|
+
total_in_period: count
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if order
|
|
96
|
+
orders << order
|
|
97
|
+
update_stats(order, period)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
print_summary
|
|
102
|
+
orders
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Generate a realistic full day of operations
|
|
106
|
+
#
|
|
107
|
+
# @param multiplier [Float] Order multiplier (0.5 = slow, 2.0 = busy)
|
|
108
|
+
# @return [Array<Hash>] Generated orders
|
|
109
|
+
def generate_realistic_day(multiplier: 1.0)
|
|
110
|
+
logger.info "Generating realistic day (multiplier: #{multiplier})..."
|
|
111
|
+
|
|
112
|
+
data = fetch_required_data
|
|
113
|
+
return [] unless data
|
|
114
|
+
|
|
115
|
+
orders = []
|
|
116
|
+
MEAL_PERIODS.each do |period, config|
|
|
117
|
+
period_count = (config[:weight] * multiplier * 0.6).round
|
|
118
|
+
period_count = [period_count, 1].max
|
|
119
|
+
|
|
120
|
+
logger.info "#{period}: #{period_count} orders"
|
|
121
|
+
|
|
122
|
+
period_count.times do |i|
|
|
123
|
+
order = create_realistic_order(
|
|
124
|
+
period: period,
|
|
125
|
+
data: data,
|
|
126
|
+
order_num: i + 1,
|
|
127
|
+
total_in_period: period_count
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if order
|
|
131
|
+
orders << order
|
|
132
|
+
update_stats(order, period)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
print_summary
|
|
138
|
+
orders
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def fetch_required_data
|
|
144
|
+
data_loader = DataLoader.new(business_type: SkytabSandboxSimulator.configuration.business_type)
|
|
145
|
+
|
|
146
|
+
items = data_loader.items
|
|
147
|
+
categories = data_loader.categories
|
|
148
|
+
tenders = data_loader.tenders
|
|
149
|
+
|
|
150
|
+
if items.empty?
|
|
151
|
+
logger.error "No items found. Run setup first."
|
|
152
|
+
return nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
{ items: items, categories: categories, tenders: tenders }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def create_realistic_order(period:, data:, order_num:, total_in_period:)
|
|
159
|
+
revenue_class = select_revenue_class(period)
|
|
160
|
+
items_count = rand(MEAL_PERIODS[period][:avg_items])
|
|
161
|
+
selected_items = data[:items].sample([items_count, 1].max)
|
|
162
|
+
|
|
163
|
+
# Build SkyTab order format
|
|
164
|
+
pos_ref = "CHK-#{Time.now.strftime('%H%M')}-#{order_num.to_s.rjust(3, '0')}"
|
|
165
|
+
employee_ref = "EMP#{format('%03d', rand(1..8))}"
|
|
166
|
+
|
|
167
|
+
# Calculate totals
|
|
168
|
+
subtotal = selected_items.sum { |item| item["price"] }
|
|
169
|
+
tax = (subtotal * SALES_TAX_RATE / 1_000_000.0).round
|
|
170
|
+
total = subtotal + tax
|
|
171
|
+
tip = calculate_tip(total, revenue_class)
|
|
172
|
+
|
|
173
|
+
# Select tender
|
|
174
|
+
tender = select_tender
|
|
175
|
+
|
|
176
|
+
# Create ticket via API
|
|
177
|
+
ticket = services.order.create_ticket(
|
|
178
|
+
pos_ref: pos_ref,
|
|
179
|
+
employee_pos_ref: employee_ref,
|
|
180
|
+
revenue_class: revenue_class
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
return nil unless ticket
|
|
184
|
+
|
|
185
|
+
ticket_id = ticket["id"] || ticket["posRef"] || pos_ref
|
|
186
|
+
|
|
187
|
+
# Add items to ticket
|
|
188
|
+
selected_items.each do |item|
|
|
189
|
+
services.order.add_item_to_ticket(
|
|
190
|
+
ticket_id,
|
|
191
|
+
pos_ref: item["sku"] || item["name"].upcase.tr(" ", "_"),
|
|
192
|
+
name: item["name"],
|
|
193
|
+
quantity: 1,
|
|
194
|
+
price: item["price"]
|
|
195
|
+
)
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
logger.warn "Failed to add item to ticket: #{e.message}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Add payment
|
|
201
|
+
services.payment.add_payment(
|
|
202
|
+
ticket_id,
|
|
203
|
+
tender_pos_ref: tender,
|
|
204
|
+
amount: total,
|
|
205
|
+
tip: tip
|
|
206
|
+
)
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
logger.warn "Failed to add payment: #{e.message}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Close ticket
|
|
212
|
+
services.order.close_ticket(
|
|
213
|
+
ticket_id,
|
|
214
|
+
subtotal: subtotal,
|
|
215
|
+
tax: tax,
|
|
216
|
+
total: total,
|
|
217
|
+
tip: tip
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Persist to local DB if connected
|
|
221
|
+
persist_order(
|
|
222
|
+
ticket_id: ticket_id,
|
|
223
|
+
pos_ref: pos_ref,
|
|
224
|
+
revenue_class: revenue_class,
|
|
225
|
+
period: period,
|
|
226
|
+
subtotal: subtotal,
|
|
227
|
+
tax: tax,
|
|
228
|
+
total: total,
|
|
229
|
+
tip: tip,
|
|
230
|
+
tender: tender,
|
|
231
|
+
items: selected_items
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
"posRef" => pos_ref,
|
|
236
|
+
"status" => "closed",
|
|
237
|
+
"revenueClassPosRef" => revenue_class,
|
|
238
|
+
"employeePosRef" => employee_ref,
|
|
239
|
+
"items" => selected_items.map do |item|
|
|
240
|
+
{ "posRef" => item["sku"] || item["name"], "name" => item["name"],
|
|
241
|
+
"quantity" => 1, "price" => item["price"], "total" => item["price"] }
|
|
242
|
+
end,
|
|
243
|
+
"payments" => [
|
|
244
|
+
{ "tenderPosRef" => tender, "amount" => total, "tip" => tip, "status" => "completed" }
|
|
245
|
+
],
|
|
246
|
+
"subtotal" => subtotal,
|
|
247
|
+
"tax" => tax,
|
|
248
|
+
"total" => total,
|
|
249
|
+
"tip" => tip
|
|
250
|
+
}
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
logger.error "Failed to create order: #{e.message}"
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def persist_order(ticket_id:, pos_ref:, revenue_class:, period:, subtotal:, tax:, total:, tip:, tender:, items:)
|
|
257
|
+
return unless Database.connected?
|
|
258
|
+
|
|
259
|
+
location_id = SkytabSandboxSimulator.configuration.location_id
|
|
260
|
+
|
|
261
|
+
order = Models::SimulatedOrder.create!(
|
|
262
|
+
skytab_ticket_id: ticket_id,
|
|
263
|
+
skytab_location_id: location_id,
|
|
264
|
+
status: "closed",
|
|
265
|
+
business_date: Date.today,
|
|
266
|
+
subtotal: subtotal,
|
|
267
|
+
tax_amount: tax,
|
|
268
|
+
tip_amount: tip,
|
|
269
|
+
discount_amount: 0,
|
|
270
|
+
total: total,
|
|
271
|
+
revenue_class: revenue_class,
|
|
272
|
+
meal_period: period.to_s,
|
|
273
|
+
metadata: { pos_ref: pos_ref, items: items.map { |i| i["name"] } }
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
Models::SimulatedPayment.create!(
|
|
277
|
+
simulated_order: order,
|
|
278
|
+
skytab_payment_id: "PAY-#{SecureRandom.hex(6).upcase}",
|
|
279
|
+
tender_name: tender,
|
|
280
|
+
amount: total,
|
|
281
|
+
tip_amount: tip,
|
|
282
|
+
tax_amount: tax,
|
|
283
|
+
status: "completed",
|
|
284
|
+
payment_type: tender == "CASH" ? "cash" : "card"
|
|
285
|
+
)
|
|
286
|
+
rescue StandardError => e
|
|
287
|
+
logger.debug "Failed to persist order: #{e.message}"
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def determine_order_count
|
|
291
|
+
day = Date.today.wday
|
|
292
|
+
pattern = case day
|
|
293
|
+
when 5 then ORDER_PATTERNS[:friday]
|
|
294
|
+
when 6 then ORDER_PATTERNS[:saturday]
|
|
295
|
+
when 0 then ORDER_PATTERNS[:sunday]
|
|
296
|
+
else ORDER_PATTERNS[:weekday]
|
|
297
|
+
end
|
|
298
|
+
rand(pattern[:min]..pattern[:max])
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def select_meal_period
|
|
302
|
+
total_weight = MEAL_PERIODS.values.sum { |v| v[:weight] }
|
|
303
|
+
roll = rand(total_weight)
|
|
304
|
+
cumulative = 0
|
|
305
|
+
|
|
306
|
+
MEAL_PERIODS.each do |period, config|
|
|
307
|
+
cumulative += config[:weight]
|
|
308
|
+
return period if roll < cumulative
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
:dinner
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def select_revenue_class(period)
|
|
315
|
+
dist = REVENUE_CLASS_BY_PERIOD[period]
|
|
316
|
+
total_weight = dist.values.sum
|
|
317
|
+
roll = rand(total_weight)
|
|
318
|
+
cumulative = 0
|
|
319
|
+
|
|
320
|
+
dist.each do |revenue_class, weight|
|
|
321
|
+
cumulative += weight
|
|
322
|
+
return revenue_class if roll < cumulative
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
"DINE_IN"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def select_tender
|
|
329
|
+
total_weight = TENDER_WEIGHTS.values.sum
|
|
330
|
+
roll = rand(total_weight)
|
|
331
|
+
cumulative = 0
|
|
332
|
+
|
|
333
|
+
TENDER_WEIGHTS.each do |tender, weight|
|
|
334
|
+
cumulative += weight
|
|
335
|
+
return tender if roll < cumulative
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
"VISA"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def calculate_tip(total, revenue_class)
|
|
342
|
+
rates = TIP_RATES[revenue_class] || TIP_RATES["DINE_IN"]
|
|
343
|
+
tip_pct = rand(rates[:min]..rates[:max])
|
|
344
|
+
(total * tip_pct / 100.0).round
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def update_stats(order, period)
|
|
348
|
+
return unless order
|
|
349
|
+
|
|
350
|
+
@stats[:orders] += 1
|
|
351
|
+
@stats[:revenue] += order["total"] || 0
|
|
352
|
+
@stats[:tips] += order["tip"] || 0
|
|
353
|
+
@stats[:tax] += order["tax"] || 0
|
|
354
|
+
@stats[:by_period][period] += 1
|
|
355
|
+
@stats[:by_revenue_class][order["revenueClassPosRef"]] += 1
|
|
356
|
+
|
|
357
|
+
tender = order.dig("payments", 0, "tenderPosRef")
|
|
358
|
+
@stats[:by_tender][tender] += 1 if tender
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def print_summary
|
|
362
|
+
logger.info "=" * 50
|
|
363
|
+
logger.info "Order Generation Summary"
|
|
364
|
+
logger.info "=" * 50
|
|
365
|
+
logger.info " Total orders: #{@stats[:orders]}"
|
|
366
|
+
logger.info " Revenue: $#{(@stats[:revenue] / 100.0).round(2)}"
|
|
367
|
+
logger.info " Tips: $#{(@stats[:tips] / 100.0).round(2)}"
|
|
368
|
+
logger.info " Tax: $#{(@stats[:tax] / 100.0).round(2)}"
|
|
369
|
+
|
|
370
|
+
if @stats[:by_period].any?
|
|
371
|
+
logger.info ""
|
|
372
|
+
logger.info " By Period:"
|
|
373
|
+
@stats[:by_period].each { |p, c| logger.info " #{p}: #{c}" }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if @stats[:by_revenue_class].any?
|
|
377
|
+
logger.info ""
|
|
378
|
+
logger.info " By Revenue Class:"
|
|
379
|
+
@stats[:by_revenue_class].each { |rc, c| logger.info " #{rc}: #{c}" }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
if @stats[:by_tender].any?
|
|
383
|
+
logger.info ""
|
|
384
|
+
logger.info " By Tender:"
|
|
385
|
+
@stats[:by_tender].each { |t, c| logger.info " #{t}: #{c}" }
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class ApiRequest < Record
|
|
6
|
+
# Validations
|
|
7
|
+
validates :http_method, presence: true
|
|
8
|
+
validates :url, presence: true
|
|
9
|
+
|
|
10
|
+
# Time scopes
|
|
11
|
+
scope :today, -> { where("created_at >= ?", Time.now.utc.beginning_of_day) }
|
|
12
|
+
scope :recent, ->(minutes = 60) { where("created_at >= ?", minutes.minutes.ago) }
|
|
13
|
+
|
|
14
|
+
# Status scopes
|
|
15
|
+
scope :errors, -> { where("response_status >= 400 OR error_message IS NOT NULL") }
|
|
16
|
+
scope :successful, -> { where("response_status < 400 AND error_message IS NULL") }
|
|
17
|
+
|
|
18
|
+
# Resource scopes
|
|
19
|
+
scope :for_resource, ->(type) { where(resource_type: type) }
|
|
20
|
+
scope :for_resource_id, ->(type, id) { where(resource_type: type, resource_id: id) }
|
|
21
|
+
|
|
22
|
+
# Location scope
|
|
23
|
+
scope :for_location, ->(location_id) {
|
|
24
|
+
sanitized = sanitize_sql_like(location_id)
|
|
25
|
+
where("url LIKE ?", "%/#{sanitized}/%")
|
|
26
|
+
.or(where("url LIKE ?", "%/#{sanitized}"))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# HTTP method scopes
|
|
30
|
+
scope :gets, -> { where(http_method: "GET") }
|
|
31
|
+
scope :posts, -> { where(http_method: "POST") }
|
|
32
|
+
scope :puts, -> { where(http_method: "PUT") }
|
|
33
|
+
scope :deletes, -> { where(http_method: "DELETE") }
|
|
34
|
+
|
|
35
|
+
# Performance
|
|
36
|
+
scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
|
|
37
|
+
|
|
38
|
+
def error?
|
|
39
|
+
error_message.present? || (response_status && response_status >= 400)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class BusinessType < Record
|
|
6
|
+
has_many :categories, dependent: :destroy
|
|
7
|
+
has_many :items, through: :categories
|
|
8
|
+
has_many :simulated_orders, dependent: :nullify
|
|
9
|
+
|
|
10
|
+
# Validations
|
|
11
|
+
validates :key, presence: true, uniqueness: true
|
|
12
|
+
validates :name, presence: true
|
|
13
|
+
|
|
14
|
+
# Scopes by industry
|
|
15
|
+
scope :food_types, -> { where(industry: "food") }
|
|
16
|
+
scope :retail_types, -> { where(industry: "retail") }
|
|
17
|
+
scope :service_types, -> { where(industry: "service") }
|
|
18
|
+
|
|
19
|
+
# Find by key (the primary lookup pattern)
|
|
20
|
+
def self.find_by_key!(key)
|
|
21
|
+
find_by!(key: key)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class Category < Record
|
|
6
|
+
belongs_to :business_type
|
|
7
|
+
has_many :items, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
# Validations
|
|
10
|
+
validates :name, presence: true
|
|
11
|
+
|
|
12
|
+
# Explicit sort scope
|
|
13
|
+
scope :sorted, -> { order(:sort_order) }
|
|
14
|
+
scope :with_items, -> { joins(:items).distinct }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class DailySummary < Record
|
|
6
|
+
# Validations
|
|
7
|
+
validates :location_id, presence: true
|
|
8
|
+
validates :business_date, presence: true,
|
|
9
|
+
uniqueness: { scope: :location_id }
|
|
10
|
+
|
|
11
|
+
# Scopes
|
|
12
|
+
scope :for_location, ->(location_id) { where(location_id: location_id) }
|
|
13
|
+
scope :on_date, ->(date) { where(business_date: date) }
|
|
14
|
+
scope :today, -> { where(business_date: Date.today) }
|
|
15
|
+
scope :between_dates, ->(from, to) { where(business_date: from..to) }
|
|
16
|
+
scope :recent, ->(days = 7) { where("business_date >= ?", days.days.ago.to_date) }
|
|
17
|
+
|
|
18
|
+
# Generate (or update) a daily summary by aggregating simulated orders.
|
|
19
|
+
# Race-condition safe: retries on unique constraint violation.
|
|
20
|
+
#
|
|
21
|
+
# @param location_id [String] SkyTab location ID
|
|
22
|
+
# @param date [Date] Business date to summarize
|
|
23
|
+
# @return [DailySummary] the created or updated summary
|
|
24
|
+
def self.generate_for!(location_id, date)
|
|
25
|
+
orders = SimulatedOrder.for_location(location_id).on_date(date).successful
|
|
26
|
+
payments = SimulatedPayment.joins(:simulated_order)
|
|
27
|
+
.where(simulated_orders: {
|
|
28
|
+
skytab_location_id: location_id,
|
|
29
|
+
business_date: date,
|
|
30
|
+
status: "closed"
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
# Build breakdown by revenue class and meal period
|
|
34
|
+
breakdown = {
|
|
35
|
+
by_meal_period: orders.group(:meal_period).count,
|
|
36
|
+
by_revenue_class: orders.group(:revenue_class).count,
|
|
37
|
+
by_tender: payments.group(:tender_name).count,
|
|
38
|
+
revenue_by_meal_period: orders.group(:meal_period).sum(:total),
|
|
39
|
+
revenue_by_revenue_class: orders.group(:revenue_class).sum(:total)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
attrs = {
|
|
43
|
+
order_count: orders.count,
|
|
44
|
+
payment_count: payments.count,
|
|
45
|
+
refund_count: SimulatedOrder.for_location(location_id).on_date(date).refunded.count,
|
|
46
|
+
total_revenue: orders.sum(:total),
|
|
47
|
+
total_tax: orders.sum(:tax_amount),
|
|
48
|
+
total_tips: orders.sum(:tip_amount),
|
|
49
|
+
total_discounts: orders.sum(:discount_amount),
|
|
50
|
+
breakdown: breakdown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
summary = find_or_initialize_by(location_id: location_id, business_date: date)
|
|
54
|
+
summary.assign_attributes(attrs)
|
|
55
|
+
summary.save!
|
|
56
|
+
summary
|
|
57
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
58
|
+
retry
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Convenience: total revenue in dollars
|
|
62
|
+
def total_revenue_dollars
|
|
63
|
+
(total_revenue || 0) / 100.0
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class Item < Record
|
|
6
|
+
belongs_to :category
|
|
7
|
+
|
|
8
|
+
# Validations
|
|
9
|
+
validates :name, presence: true
|
|
10
|
+
validates :price, presence: true,
|
|
11
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
12
|
+
|
|
13
|
+
# Scopes
|
|
14
|
+
scope :active, -> { where(active: true) }
|
|
15
|
+
scope :inactive, -> { where(active: false) }
|
|
16
|
+
|
|
17
|
+
scope :for_business_type, ->(key) {
|
|
18
|
+
joins(category: :business_type)
|
|
19
|
+
.where(business_types: { key: key })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
scope :in_category, ->(category_name) {
|
|
23
|
+
joins(:category).where(categories: { name: category_name })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Price in dollars (convenience)
|
|
27
|
+
def price_dollars
|
|
28
|
+
(price || 0) / 100.0
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module SkytabSandboxSimulator
|
|
6
|
+
module Models
|
|
7
|
+
# Shared base class for all simulator ActiveRecord models.
|
|
8
|
+
#
|
|
9
|
+
# Equivalent to ApplicationRecord in Rails, but for standalone usage.
|
|
10
|
+
class Record < ::ActiveRecord::Base
|
|
11
|
+
self.abstract_class = true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class SimulatedOrder < Record
|
|
6
|
+
belongs_to :business_type, optional: true
|
|
7
|
+
has_many :simulated_payments, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
# Validations
|
|
10
|
+
validates :skytab_location_id, presence: true
|
|
11
|
+
validates :status, presence: true
|
|
12
|
+
validates :business_date, presence: true
|
|
13
|
+
|
|
14
|
+
# Status scopes
|
|
15
|
+
scope :successful, -> { where(status: "closed") }
|
|
16
|
+
scope :open_orders, -> { where(status: "open") }
|
|
17
|
+
scope :refunded, -> { where(status: "refunded") }
|
|
18
|
+
scope :voided, -> { where(status: "voided") }
|
|
19
|
+
|
|
20
|
+
# Time scopes
|
|
21
|
+
scope :today, -> { where(business_date: Date.today) }
|
|
22
|
+
scope :on_date, ->(date) { where(business_date: date) }
|
|
23
|
+
scope :between_dates, ->(from, to) { where(business_date: from..to) }
|
|
24
|
+
|
|
25
|
+
# Filter scopes
|
|
26
|
+
scope :for_location, ->(location_id) { where(skytab_location_id: location_id) }
|
|
27
|
+
scope :for_revenue_class, ->(revenue_class) { where(revenue_class: revenue_class) }
|
|
28
|
+
scope :for_meal_period, ->(period) { where(meal_period: period) }
|
|
29
|
+
|
|
30
|
+
# Amounts in dollars (convenience)
|
|
31
|
+
def total_dollars
|
|
32
|
+
(total || 0) / 100.0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def subtotal_dollars
|
|
36
|
+
(subtotal || 0) / 100.0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class SimulatedPayment < Record
|
|
6
|
+
belongs_to :simulated_order
|
|
7
|
+
|
|
8
|
+
# Validations
|
|
9
|
+
validates :tender_name, presence: true
|
|
10
|
+
validates :status, presence: true
|
|
11
|
+
validates :amount, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
12
|
+
|
|
13
|
+
# Status scopes
|
|
14
|
+
scope :successful, -> { where(status: "completed") }
|
|
15
|
+
scope :pending, -> { where(status: "pending") }
|
|
16
|
+
scope :refunded, -> { where(status: "refunded") }
|
|
17
|
+
|
|
18
|
+
# Tender scopes
|
|
19
|
+
scope :cash, -> { where(tender_name: "Cash") }
|
|
20
|
+
scope :by_tender, ->(name) { where(tender_name: name) }
|
|
21
|
+
|
|
22
|
+
# Amount in dollars (convenience)
|
|
23
|
+
def amount_dollars
|
|
24
|
+
(amount || 0) / 100.0
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|