clover_sandbox_simulator 1.0.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/README.md +316 -0
- data/bin/simulate +209 -0
- data/lib/clover_sandbox_simulator/configuration.rb +51 -0
- data/lib/clover_sandbox_simulator/data/restaurant/categories.json +39 -0
- data/lib/clover_sandbox_simulator/data/restaurant/discounts.json +39 -0
- data/lib/clover_sandbox_simulator/data/restaurant/items.json +238 -0
- data/lib/clover_sandbox_simulator/data/restaurant/modifiers.json +62 -0
- data/lib/clover_sandbox_simulator/data/restaurant/tenders.json +41 -0
- data/lib/clover_sandbox_simulator/generators/data_loader.rb +54 -0
- data/lib/clover_sandbox_simulator/generators/entity_generator.rb +164 -0
- data/lib/clover_sandbox_simulator/generators/order_generator.rb +540 -0
- data/lib/clover_sandbox_simulator/services/base_service.rb +111 -0
- data/lib/clover_sandbox_simulator/services/clover/customer_service.rb +82 -0
- data/lib/clover_sandbox_simulator/services/clover/discount_service.rb +58 -0
- data/lib/clover_sandbox_simulator/services/clover/employee_service.rb +82 -0
- data/lib/clover_sandbox_simulator/services/clover/inventory_service.rb +120 -0
- data/lib/clover_sandbox_simulator/services/clover/order_service.rb +170 -0
- data/lib/clover_sandbox_simulator/services/clover/payment_service.rb +123 -0
- data/lib/clover_sandbox_simulator/services/clover/services_manager.rb +49 -0
- data/lib/clover_sandbox_simulator/services/clover/tax_service.rb +53 -0
- data/lib/clover_sandbox_simulator/services/clover/tender_service.rb +117 -0
- data/lib/clover_sandbox_simulator.rb +43 -0
- metadata +195 -0
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CloverSandboxSimulator
|
|
4
|
+
module Generators
|
|
5
|
+
# Generates realistic restaurant orders 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..2 },
|
|
10
|
+
lunch: { hours: 11..14, weight: 30, avg_items: 2..5, avg_party: 1..4 },
|
|
11
|
+
happy_hour: { hours: 15..17, weight: 10, avg_items: 2..4, avg_party: 2..4 },
|
|
12
|
+
dinner: { hours: 17..21, weight: 35, avg_items: 3..6, avg_party: 2..6 },
|
|
13
|
+
late_night: { hours: 21..23, weight: 10, avg_items: 2..4, avg_party: 1..3 }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# Dining option distributions by meal period
|
|
17
|
+
DINING_BY_PERIOD = {
|
|
18
|
+
breakfast: { "HERE" => 40, "TO_GO" => 50, "DELIVERY" => 10 },
|
|
19
|
+
lunch: { "HERE" => 35, "TO_GO" => 45, "DELIVERY" => 20 },
|
|
20
|
+
happy_hour: { "HERE" => 80, "TO_GO" => 15, "DELIVERY" => 5 },
|
|
21
|
+
dinner: { "HERE" => 70, "TO_GO" => 15, "DELIVERY" => 15 },
|
|
22
|
+
late_night: { "HERE" => 50, "TO_GO" => 30, "DELIVERY" => 20 }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# Tip percentages by dining option
|
|
26
|
+
TIP_RATES = {
|
|
27
|
+
"HERE" => { min: 15, max: 25 }, # Dine-in tips higher
|
|
28
|
+
"TO_GO" => { min: 0, max: 15 }, # Takeout tips lower
|
|
29
|
+
"DELIVERY" => { min: 10, max: 20 } # Delivery tips moderate
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Order patterns by day of week
|
|
33
|
+
ORDER_PATTERNS = {
|
|
34
|
+
weekday: { min: 40, max: 60 },
|
|
35
|
+
friday: { min: 70, max: 100 },
|
|
36
|
+
saturday: { min: 80, max: 120 },
|
|
37
|
+
sunday: { min: 50, max: 80 }
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Category preferences by meal period
|
|
41
|
+
CATEGORY_PREFERENCES = {
|
|
42
|
+
breakfast: ["Drinks", "Sides"],
|
|
43
|
+
lunch: ["Appetizers", "Entrees", "Sides", "Drinks"],
|
|
44
|
+
happy_hour: ["Appetizers", "Alcoholic Beverages", "Drinks"],
|
|
45
|
+
dinner: ["Appetizers", "Entrees", "Sides", "Desserts", "Alcoholic Beverages", "Drinks"],
|
|
46
|
+
late_night: ["Appetizers", "Entrees", "Alcoholic Beverages", "Desserts"]
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
attr_reader :services, :logger, :stats
|
|
50
|
+
|
|
51
|
+
def initialize(services: nil)
|
|
52
|
+
@services = services || Services::Clover::ServicesManager.new
|
|
53
|
+
@logger = CloverSandboxSimulator.logger
|
|
54
|
+
@stats = { orders: 0, revenue: 0, tips: 0, tax: 0, by_period: {}, by_dining: {} }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Generate a realistic day of restaurant operations
|
|
58
|
+
def generate_realistic_day(date: Date.today, multiplier: 1.0)
|
|
59
|
+
count = (order_count_for_date(date) * multiplier).to_i
|
|
60
|
+
|
|
61
|
+
logger.info "=" * 60
|
|
62
|
+
logger.info "🍽️ Generating realistic restaurant day: #{date}"
|
|
63
|
+
logger.info " Target orders: #{count}"
|
|
64
|
+
logger.info " Day: #{date.strftime('%A')}"
|
|
65
|
+
logger.info "=" * 60
|
|
66
|
+
|
|
67
|
+
# Fetch required data
|
|
68
|
+
data = fetch_required_data
|
|
69
|
+
return [] unless data
|
|
70
|
+
|
|
71
|
+
# Distribute orders across meal periods
|
|
72
|
+
period_orders = distribute_orders_by_period(count)
|
|
73
|
+
|
|
74
|
+
orders = []
|
|
75
|
+
period_orders.each do |period, period_count|
|
|
76
|
+
logger.info "-" * 40
|
|
77
|
+
logger.info "📍 #{period.to_s.upcase} SERVICE: #{period_count} orders"
|
|
78
|
+
|
|
79
|
+
period_count.times do |i|
|
|
80
|
+
order = create_realistic_order(
|
|
81
|
+
period: period,
|
|
82
|
+
data: data,
|
|
83
|
+
order_num: i + 1,
|
|
84
|
+
total_in_period: period_count
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if order
|
|
88
|
+
orders << order
|
|
89
|
+
update_stats(order, period)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
print_summary
|
|
95
|
+
orders
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Generate orders for today (simple mode)
|
|
99
|
+
def generate_today(count: nil)
|
|
100
|
+
if count
|
|
101
|
+
generate_for_date(Date.today, count: count)
|
|
102
|
+
else
|
|
103
|
+
generate_realistic_day
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Generate specific count of orders
|
|
108
|
+
def generate_for_date(date, count:)
|
|
109
|
+
logger.info "=" * 60
|
|
110
|
+
logger.info "Generating #{count} orders for #{date}"
|
|
111
|
+
logger.info "=" * 60
|
|
112
|
+
|
|
113
|
+
data = fetch_required_data
|
|
114
|
+
return [] unless data
|
|
115
|
+
|
|
116
|
+
orders = []
|
|
117
|
+
count.times do |i|
|
|
118
|
+
period = weighted_random_period
|
|
119
|
+
logger.info "-" * 40
|
|
120
|
+
logger.info "Creating order #{i + 1}/#{count} (#{period})"
|
|
121
|
+
|
|
122
|
+
order = create_realistic_order(
|
|
123
|
+
period: period,
|
|
124
|
+
data: data,
|
|
125
|
+
order_num: i + 1,
|
|
126
|
+
total_in_period: count
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if order
|
|
130
|
+
orders << order
|
|
131
|
+
update_stats(order, period)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
print_summary
|
|
136
|
+
orders
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def fetch_required_data
|
|
142
|
+
items = services.inventory.get_items
|
|
143
|
+
employees = services.employee.get_employees
|
|
144
|
+
customers = services.customer.get_customers
|
|
145
|
+
tenders = services.tender.get_safe_tenders
|
|
146
|
+
discounts = services.discount.get_discounts
|
|
147
|
+
|
|
148
|
+
if items.empty?
|
|
149
|
+
logger.error "No items found! Please run setup first."
|
|
150
|
+
return nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if employees.empty?
|
|
154
|
+
logger.error "No employees found! Please run setup first."
|
|
155
|
+
return nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
if tenders.empty?
|
|
159
|
+
logger.error "No safe tenders found!"
|
|
160
|
+
return nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Categorize items for meal period selection
|
|
164
|
+
items_by_category = items.group_by { |i| i.dig("categories", "elements", 0, "name") || "Other" }
|
|
165
|
+
|
|
166
|
+
{
|
|
167
|
+
items: items,
|
|
168
|
+
items_by_category: items_by_category,
|
|
169
|
+
employees: employees,
|
|
170
|
+
customers: customers,
|
|
171
|
+
tenders: tenders,
|
|
172
|
+
discounts: discounts
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def distribute_orders_by_period(total_count)
|
|
177
|
+
total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
|
|
178
|
+
|
|
179
|
+
distribution = {}
|
|
180
|
+
remaining = total_count
|
|
181
|
+
|
|
182
|
+
MEAL_PERIODS.each_with_index do |(period, config), index|
|
|
183
|
+
if index == MEAL_PERIODS.size - 1
|
|
184
|
+
distribution[period] = remaining
|
|
185
|
+
else
|
|
186
|
+
count = ((config[:weight].to_f / total_weight) * total_count).round
|
|
187
|
+
distribution[period] = [count, remaining].min
|
|
188
|
+
remaining -= distribution[period]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
distribution
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def weighted_random_period
|
|
196
|
+
total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
|
|
197
|
+
random = rand(total_weight)
|
|
198
|
+
|
|
199
|
+
cumulative = 0
|
|
200
|
+
MEAL_PERIODS.each do |period, config|
|
|
201
|
+
cumulative += config[:weight]
|
|
202
|
+
return period if random < cumulative
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
:dinner # fallback
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def create_realistic_order(period:, data:, order_num:, total_in_period:)
|
|
209
|
+
config = MEAL_PERIODS[period]
|
|
210
|
+
employee = data[:employees].sample
|
|
211
|
+
|
|
212
|
+
# 60% of orders have customer info (regulars, rewards members)
|
|
213
|
+
customer = data[:customers].sample if rand < 0.6
|
|
214
|
+
|
|
215
|
+
# Create order shell
|
|
216
|
+
order = services.order.create_order(
|
|
217
|
+
employee_id: employee["id"],
|
|
218
|
+
customer_id: customer&.dig("id")
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return nil unless order && order["id"]
|
|
222
|
+
|
|
223
|
+
order_id = order["id"]
|
|
224
|
+
logger.info "Created order: #{order_id}"
|
|
225
|
+
|
|
226
|
+
# Set dining option based on meal period
|
|
227
|
+
dining = select_dining_option(period)
|
|
228
|
+
services.order.set_dining_option(order_id, dining)
|
|
229
|
+
logger.debug " Dining: #{dining}"
|
|
230
|
+
|
|
231
|
+
# Party size affects item count
|
|
232
|
+
party_size = rand(config[:avg_party])
|
|
233
|
+
base_items = rand(config[:avg_items])
|
|
234
|
+
num_items = [base_items + (party_size / 2), 1].max
|
|
235
|
+
|
|
236
|
+
# Select items appropriate for the meal period
|
|
237
|
+
selected_items = select_items_for_period(period, data, num_items, party_size)
|
|
238
|
+
|
|
239
|
+
line_items = selected_items.map do |item|
|
|
240
|
+
# Quantity varies by party size
|
|
241
|
+
quantity = party_size > 2 && rand < 0.3 ? rand(2..3) : 1
|
|
242
|
+
note = random_note if rand < 0.15
|
|
243
|
+
|
|
244
|
+
{
|
|
245
|
+
item_id: item["id"],
|
|
246
|
+
quantity: quantity,
|
|
247
|
+
note: note
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
services.order.add_line_items(order_id, line_items)
|
|
252
|
+
|
|
253
|
+
# Apply discount (15% chance, higher for regulars)
|
|
254
|
+
discount_chance = customer ? 0.20 : 0.10
|
|
255
|
+
if rand < discount_chance && data[:discounts].any?
|
|
256
|
+
discount = select_appropriate_discount(data[:discounts], period)
|
|
257
|
+
services.order.apply_discount(order_id, discount_id: discount["id"]) if discount
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Calculate totals
|
|
261
|
+
subtotal = services.order.calculate_total(order_id)
|
|
262
|
+
services.order.update_total(order_id, subtotal)
|
|
263
|
+
|
|
264
|
+
# Calculate tax and tip (tip varies by dining option)
|
|
265
|
+
tax_amount = services.tax.calculate_tax(subtotal)
|
|
266
|
+
tip_amount = calculate_tip(subtotal, dining, party_size)
|
|
267
|
+
|
|
268
|
+
# Process payment
|
|
269
|
+
process_order_payment(
|
|
270
|
+
order_id: order_id,
|
|
271
|
+
subtotal: subtotal,
|
|
272
|
+
tax_amount: tax_amount,
|
|
273
|
+
tip_amount: tip_amount,
|
|
274
|
+
employee_id: employee["id"],
|
|
275
|
+
tenders: data[:tenders],
|
|
276
|
+
dining: dining,
|
|
277
|
+
party_size: party_size
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Update order state to paid
|
|
281
|
+
services.order.update_state(order_id, "paid")
|
|
282
|
+
|
|
283
|
+
# Return final order with metadata
|
|
284
|
+
final_order = services.order.get_order(order_id)
|
|
285
|
+
final_order["_metadata"] = {
|
|
286
|
+
period: period,
|
|
287
|
+
dining: dining,
|
|
288
|
+
party_size: party_size,
|
|
289
|
+
tip: tip_amount,
|
|
290
|
+
tax: tax_amount
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
final_order
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def select_dining_option(period)
|
|
297
|
+
distribution = DINING_BY_PERIOD[period]
|
|
298
|
+
random = rand(100)
|
|
299
|
+
|
|
300
|
+
cumulative = 0
|
|
301
|
+
distribution.each do |option, weight|
|
|
302
|
+
cumulative += weight
|
|
303
|
+
return option if random < cumulative
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
"HERE"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def select_items_for_period(period, data, count, party_size)
|
|
310
|
+
preferred_categories = CATEGORY_PREFERENCES[period] || CATEGORY_PREFERENCES[:dinner]
|
|
311
|
+
|
|
312
|
+
# Build weighted item pool
|
|
313
|
+
weighted_items = []
|
|
314
|
+
|
|
315
|
+
preferred_categories.each do |category|
|
|
316
|
+
items = data[:items_by_category][category] || []
|
|
317
|
+
# Add preferred items with higher weight
|
|
318
|
+
items.each { |item| weighted_items.concat([item] * 3) }
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Add all items with lower weight for variety
|
|
322
|
+
data[:items].each { |item| weighted_items << item }
|
|
323
|
+
|
|
324
|
+
# For larger parties, ensure variety
|
|
325
|
+
if party_size >= 4
|
|
326
|
+
# Try to get items from different categories
|
|
327
|
+
selected = []
|
|
328
|
+
preferred_categories.each do |category|
|
|
329
|
+
items = data[:items_by_category][category] || []
|
|
330
|
+
selected << items.sample if items.any? && selected.size < count
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Fill remaining with weighted random (with safeguard against infinite loop)
|
|
334
|
+
unique_items = weighted_items.uniq
|
|
335
|
+
max_attempts = unique_items.size * 2
|
|
336
|
+
attempts = 0
|
|
337
|
+
while selected.size < count && attempts < max_attempts
|
|
338
|
+
item = weighted_items.sample
|
|
339
|
+
selected << item unless selected.include?(item)
|
|
340
|
+
attempts += 1
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
selected.take(count)
|
|
344
|
+
else
|
|
345
|
+
weighted_items.sample(count).uniq.take(count)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def select_appropriate_discount(discounts, period)
|
|
350
|
+
# Happy hour discounts more likely during happy hour
|
|
351
|
+
if period == :happy_hour
|
|
352
|
+
happy_discount = discounts.find { |d| d["name"]&.downcase&.include?("happy") }
|
|
353
|
+
return happy_discount if happy_discount && rand < 0.5
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
discounts.sample
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def calculate_tip(subtotal, dining, party_size)
|
|
360
|
+
rates = TIP_RATES[dining] || TIP_RATES["HERE"]
|
|
361
|
+
|
|
362
|
+
# Base tip percentage
|
|
363
|
+
tip_percent = rand(rates[:min]..rates[:max])
|
|
364
|
+
|
|
365
|
+
# Larger parties sometimes tip less per person but more total
|
|
366
|
+
if party_size >= 6
|
|
367
|
+
tip_percent = [tip_percent, 18].max # Auto-grat for large parties
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Some people don't tip on takeout
|
|
371
|
+
if dining == "TO_GO" && rand < 0.3
|
|
372
|
+
tip_percent = 0
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
(subtotal * tip_percent / 100.0).round
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def process_order_payment(order_id:, subtotal:, tax_amount:, tip_amount:, employee_id:, tenders:, dining:, party_size:)
|
|
379
|
+
# Ensure party_size is a valid number
|
|
380
|
+
party_size = party_size.to_i
|
|
381
|
+
party_size = 1 if party_size < 1
|
|
382
|
+
|
|
383
|
+
# Split payment more likely for larger parties dining in
|
|
384
|
+
split_chance = dining == "HERE" && party_size >= 2 ? 0.25 : 0.05
|
|
385
|
+
|
|
386
|
+
if rand < split_chance && tenders.size > 1
|
|
387
|
+
num_splits = [party_size, 4, tenders.size].min
|
|
388
|
+
num_splits = num_splits < 2 ? 2 : rand(2..num_splits)
|
|
389
|
+
splits = select_split_tenders(tenders, num_splits)
|
|
390
|
+
|
|
391
|
+
logger.debug " Split payment: #{num_splits} ways"
|
|
392
|
+
|
|
393
|
+
services.payment.process_split_payment(
|
|
394
|
+
order_id: order_id,
|
|
395
|
+
total_amount: subtotal,
|
|
396
|
+
tip_amount: tip_amount,
|
|
397
|
+
tax_amount: tax_amount,
|
|
398
|
+
employee_id: employee_id,
|
|
399
|
+
splits: splits
|
|
400
|
+
)
|
|
401
|
+
else
|
|
402
|
+
# Cash more common for smaller orders
|
|
403
|
+
tender = if subtotal < 2000 && rand < 0.4
|
|
404
|
+
tenders.find { |t| t["label"]&.downcase == "cash" } || tenders.sample
|
|
405
|
+
else
|
|
406
|
+
tenders.sample
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
services.payment.process_payment(
|
|
410
|
+
order_id: order_id,
|
|
411
|
+
amount: subtotal,
|
|
412
|
+
tender_id: tender["id"],
|
|
413
|
+
employee_id: employee_id,
|
|
414
|
+
tip_amount: tip_amount,
|
|
415
|
+
tax_amount: tax_amount
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def select_split_tenders(tenders, count)
|
|
421
|
+
return [] if tenders.nil? || tenders.empty? || count.nil? || count < 1
|
|
422
|
+
|
|
423
|
+
actual_count = [count.to_i, tenders.size].min
|
|
424
|
+
selected = tenders.sample(actual_count)
|
|
425
|
+
percentages = generate_split_percentages(selected.size)
|
|
426
|
+
|
|
427
|
+
selected.zip(percentages).map do |tender, pct|
|
|
428
|
+
{ tender: tender, percentage: pct }
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def generate_split_percentages(count)
|
|
433
|
+
return [100] if count == 1
|
|
434
|
+
|
|
435
|
+
# More realistic even splits
|
|
436
|
+
if rand < 0.7
|
|
437
|
+
# Even split
|
|
438
|
+
base = 100 / count
|
|
439
|
+
remainder = 100 % count
|
|
440
|
+
percentages = Array.new(count, base)
|
|
441
|
+
percentages[0] += remainder
|
|
442
|
+
percentages
|
|
443
|
+
else
|
|
444
|
+
# Random split
|
|
445
|
+
points = Array.new(count - 1) { rand(20..80) }.sort
|
|
446
|
+
|
|
447
|
+
percentages = []
|
|
448
|
+
prev = 0
|
|
449
|
+
points.each do |point|
|
|
450
|
+
percentages << (point - prev)
|
|
451
|
+
prev = point
|
|
452
|
+
end
|
|
453
|
+
percentages << (100 - prev)
|
|
454
|
+
|
|
455
|
+
percentages
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def order_count_for_date(date)
|
|
460
|
+
pattern = case date.wday
|
|
461
|
+
when 0 then ORDER_PATTERNS[:sunday]
|
|
462
|
+
when 5 then ORDER_PATTERNS[:friday]
|
|
463
|
+
when 6 then ORDER_PATTERNS[:saturday]
|
|
464
|
+
else ORDER_PATTERNS[:weekday]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
rand(pattern[:min]..pattern[:max])
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def update_stats(order, period)
|
|
471
|
+
@stats[:orders] += 1
|
|
472
|
+
|
|
473
|
+
metadata = order["_metadata"] || {}
|
|
474
|
+
subtotal = order["total"] || 0
|
|
475
|
+
tip = metadata[:tip] || 0
|
|
476
|
+
tax = metadata[:tax] || 0
|
|
477
|
+
dining = metadata[:dining] || "HERE"
|
|
478
|
+
|
|
479
|
+
@stats[:revenue] += subtotal
|
|
480
|
+
@stats[:tips] += tip
|
|
481
|
+
@stats[:tax] += tax
|
|
482
|
+
|
|
483
|
+
@stats[:by_period][period] ||= { orders: 0, revenue: 0 }
|
|
484
|
+
@stats[:by_period][period][:orders] += 1
|
|
485
|
+
@stats[:by_period][period][:revenue] += subtotal
|
|
486
|
+
|
|
487
|
+
@stats[:by_dining][dining] ||= { orders: 0, revenue: 0 }
|
|
488
|
+
@stats[:by_dining][dining][:orders] += 1
|
|
489
|
+
@stats[:by_dining][dining][:revenue] += subtotal
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def print_summary
|
|
493
|
+
logger.info ""
|
|
494
|
+
logger.info "=" * 60
|
|
495
|
+
logger.info "📊 DAILY SUMMARY"
|
|
496
|
+
logger.info "=" * 60
|
|
497
|
+
logger.info " Total Orders: #{@stats[:orders]}"
|
|
498
|
+
logger.info " Revenue: $#{'%.2f' % (@stats[:revenue] / 100.0)}"
|
|
499
|
+
logger.info " Tips: $#{'%.2f' % (@stats[:tips] / 100.0)}"
|
|
500
|
+
logger.info " Tax: $#{'%.2f' % (@stats[:tax] / 100.0)}"
|
|
501
|
+
logger.info " Grand Total: $#{'%.2f' % ((@stats[:revenue] + @stats[:tips] + @stats[:tax]) / 100.0)}"
|
|
502
|
+
logger.info ""
|
|
503
|
+
logger.info "📍 BY MEAL PERIOD:"
|
|
504
|
+
@stats[:by_period].each do |period, data|
|
|
505
|
+
avg = data[:orders] > 0 ? data[:revenue] / data[:orders] / 100.0 : 0
|
|
506
|
+
logger.info " #{period.to_s.ljust(12)} #{data[:orders].to_s.rjust(3)} orders | $#{'%.2f' % (data[:revenue] / 100.0)} | avg $#{'%.2f' % avg}"
|
|
507
|
+
end
|
|
508
|
+
logger.info ""
|
|
509
|
+
logger.info "🍽️ BY DINING OPTION:"
|
|
510
|
+
@stats[:by_dining].each do |dining, data|
|
|
511
|
+
logger.info " #{dining.ljust(12)} #{data[:orders].to_s.rjust(3)} orders | $#{'%.2f' % (data[:revenue] / 100.0)}"
|
|
512
|
+
end
|
|
513
|
+
logger.info "=" * 60
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def random_note
|
|
517
|
+
notes = [
|
|
518
|
+
"No onions",
|
|
519
|
+
"Extra spicy",
|
|
520
|
+
"Gluten-free",
|
|
521
|
+
"Allergic to nuts",
|
|
522
|
+
"Light ice",
|
|
523
|
+
"No salt",
|
|
524
|
+
"Well done",
|
|
525
|
+
"Medium rare",
|
|
526
|
+
"Extra sauce on side",
|
|
527
|
+
"Dressing on side",
|
|
528
|
+
"No cheese",
|
|
529
|
+
"Add bacon",
|
|
530
|
+
"Birthday celebration",
|
|
531
|
+
"Anniversary dinner",
|
|
532
|
+
"VIP customer",
|
|
533
|
+
"Rush order",
|
|
534
|
+
"Separate checks"
|
|
535
|
+
]
|
|
536
|
+
notes.sample
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CloverSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
# Base service for all API interactions
|
|
6
|
+
# Provides HTTP client, logging, and error handling
|
|
7
|
+
class BaseService
|
|
8
|
+
attr_reader :config, :logger
|
|
9
|
+
|
|
10
|
+
def initialize(config: nil)
|
|
11
|
+
@config = config || CloverSandboxSimulator.configuration
|
|
12
|
+
@config.validate!
|
|
13
|
+
@logger = @config.logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
protected
|
|
17
|
+
|
|
18
|
+
# Make HTTP request to Clover API
|
|
19
|
+
#
|
|
20
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete)
|
|
21
|
+
# @param path [String] API endpoint path
|
|
22
|
+
# @param payload [Hash, nil] Request body for POST/PUT
|
|
23
|
+
# @param params [Hash, nil] Query parameters
|
|
24
|
+
# @return [Hash, nil] Parsed JSON response
|
|
25
|
+
def request(method, path, payload: nil, params: nil)
|
|
26
|
+
url = build_url(path, params)
|
|
27
|
+
|
|
28
|
+
log_request(method, url, payload)
|
|
29
|
+
start_time = Time.now
|
|
30
|
+
|
|
31
|
+
response = execute_request(method, url, payload)
|
|
32
|
+
|
|
33
|
+
duration_ms = ((Time.now - start_time) * 1000).round(2)
|
|
34
|
+
log_response(response, duration_ms)
|
|
35
|
+
|
|
36
|
+
parse_response(response)
|
|
37
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
38
|
+
handle_api_error(e)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
logger.error "Request failed: #{e.message}"
|
|
41
|
+
raise ApiError, e.message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Build endpoint path with merchant ID
|
|
45
|
+
#
|
|
46
|
+
# @param path [String] Relative path after merchant ID
|
|
47
|
+
# @return [String] Full endpoint path
|
|
48
|
+
def endpoint(path)
|
|
49
|
+
"v3/merchants/#{config.merchant_id}/#{path}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def headers
|
|
55
|
+
{
|
|
56
|
+
"Authorization" => "Bearer #{config.api_token}",
|
|
57
|
+
"Content-Type" => "application/json",
|
|
58
|
+
"Accept" => "application/json"
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_url(path, params = nil)
|
|
63
|
+
base = path.start_with?("http") ? path : "#{config.environment}#{path}"
|
|
64
|
+
return base unless params&.any?
|
|
65
|
+
|
|
66
|
+
uri = URI(base)
|
|
67
|
+
uri.query = URI.encode_www_form(params)
|
|
68
|
+
uri.to_s
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def execute_request(method, url, payload)
|
|
72
|
+
case method
|
|
73
|
+
when :get then RestClient.get(url, headers)
|
|
74
|
+
when :post then RestClient.post(url, payload&.to_json, headers)
|
|
75
|
+
when :put then RestClient.put(url, payload&.to_json, headers)
|
|
76
|
+
when :delete then RestClient.delete(url, headers)
|
|
77
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_response(response)
|
|
82
|
+
return nil if response.body.nil? || response.body.empty?
|
|
83
|
+
|
|
84
|
+
JSON.parse(response.body)
|
|
85
|
+
rescue JSON::ParserError => e
|
|
86
|
+
logger.error "Failed to parse response: #{e.message}"
|
|
87
|
+
raise ApiError, "Invalid JSON response"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_api_error(error)
|
|
91
|
+
body = begin
|
|
92
|
+
JSON.parse(error.response.body)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
{ "message" => error.response.body }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
logger.error "API Error (#{error.http_code}): #{body}"
|
|
98
|
+
raise ApiError, "HTTP #{error.http_code}: #{body["message"] || body}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def log_request(method, url, payload)
|
|
102
|
+
logger.debug "→ #{method.to_s.upcase} #{url}"
|
|
103
|
+
logger.debug " Payload: #{payload.inspect}" if payload
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def log_response(response, duration_ms)
|
|
107
|
+
logger.debug "← #{response.code} (#{duration_ms}ms)"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|