revel_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/LICENSE +21 -0
- data/README.md +305 -0
- data/bin/simulate +187 -0
- data/lib/revel_sandbox_simulator/configuration.rb +109 -0
- data/lib/revel_sandbox_simulator/data/bar_nightclub/categories.json +7 -0
- data/lib/revel_sandbox_simulator/data/bar_nightclub/items.json +27 -0
- data/lib/revel_sandbox_simulator/data/bar_nightclub/tenders.json +7 -0
- data/lib/revel_sandbox_simulator/data/cafe_bakery/categories.json +7 -0
- data/lib/revel_sandbox_simulator/data/cafe_bakery/items.json +27 -0
- data/lib/revel_sandbox_simulator/data/cafe_bakery/tenders.json +7 -0
- data/lib/revel_sandbox_simulator/data/restaurant/categories.json +7 -0
- data/lib/revel_sandbox_simulator/data/restaurant/items.json +27 -0
- data/lib/revel_sandbox_simulator/data/restaurant/tenders.json +7 -0
- data/lib/revel_sandbox_simulator/data/retail_general/categories.json +7 -0
- data/lib/revel_sandbox_simulator/data/retail_general/items.json +27 -0
- data/lib/revel_sandbox_simulator/data/retail_general/tenders.json +7 -0
- data/lib/revel_sandbox_simulator/database.rb +92 -0
- data/lib/revel_sandbox_simulator/db/factories/api_requests.rb +15 -0
- data/lib/revel_sandbox_simulator/db/factories/business_types.rb +34 -0
- data/lib/revel_sandbox_simulator/db/factories/categories.rb +10 -0
- data/lib/revel_sandbox_simulator/db/factories/items.rb +12 -0
- data/lib/revel_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
- data/lib/revel_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000001_enable_pgcrypto.rb +7 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000002_create_business_types.rb +16 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000003_create_categories.rb +16 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000004_create_items.rb +19 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000005_create_simulated_orders.rb +27 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000006_create_simulated_payments.rb +22 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000007_create_api_requests.rb +22 -0
- data/lib/revel_sandbox_simulator/db/migrate/20260313000008_create_daily_summaries.rb +21 -0
- data/lib/revel_sandbox_simulator/generators/data_loader.rb +80 -0
- data/lib/revel_sandbox_simulator/generators/entity_generator.rb +61 -0
- data/lib/revel_sandbox_simulator/generators/order_generator.rb +260 -0
- data/lib/revel_sandbox_simulator/models/api_request.rb +11 -0
- data/lib/revel_sandbox_simulator/models/business_type.rb +14 -0
- data/lib/revel_sandbox_simulator/models/category.rb +15 -0
- data/lib/revel_sandbox_simulator/models/daily_summary.rb +53 -0
- data/lib/revel_sandbox_simulator/models/item.rb +18 -0
- data/lib/revel_sandbox_simulator/models/record.rb +9 -0
- data/lib/revel_sandbox_simulator/models/simulated_order.rb +20 -0
- data/lib/revel_sandbox_simulator/models/simulated_payment.rb +16 -0
- data/lib/revel_sandbox_simulator/seeder.rb +115 -0
- data/lib/revel_sandbox_simulator/services/base_service.rb +143 -0
- data/lib/revel_sandbox_simulator/services/revel/customer_service.rb +53 -0
- data/lib/revel_sandbox_simulator/services/revel/establishment_service.rb +19 -0
- data/lib/revel_sandbox_simulator/services/revel/order_service.rb +77 -0
- data/lib/revel_sandbox_simulator/services/revel/payment_service.rb +52 -0
- data/lib/revel_sandbox_simulator/services/revel/product_service.rb +87 -0
- data/lib/revel_sandbox_simulator/services/revel/services_manager.rb +45 -0
- data/lib/revel_sandbox_simulator/version.rb +5 -0
- data/lib/revel_sandbox_simulator.rb +38 -0
- metadata +335 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Generators
|
|
5
|
+
class OrderGenerator
|
|
6
|
+
MEAL_PERIODS = {
|
|
7
|
+
breakfast: { start_hour: 7, end_hour: 10, weight: 15 },
|
|
8
|
+
lunch: { start_hour: 11, end_hour: 14, weight: 30 },
|
|
9
|
+
happy_hour: { start_hour: 15, end_hour: 17, weight: 10 },
|
|
10
|
+
dinner: { start_hour: 17, end_hour: 21, weight: 35 },
|
|
11
|
+
late_night: { start_hour: 21, end_hour: 23, weight: 10 }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
DINING_BY_PERIOD = {
|
|
15
|
+
breakfast: { dine_in: 40, takeout: 50, delivery: 10 },
|
|
16
|
+
lunch: { dine_in: 35, takeout: 45, delivery: 20 },
|
|
17
|
+
happy_hour: { dine_in: 80, takeout: 15, delivery: 5 },
|
|
18
|
+
dinner: { dine_in: 70, takeout: 15, delivery: 15 },
|
|
19
|
+
late_night: { dine_in: 50, takeout: 30, delivery: 20 }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
ORDER_PATTERNS = {
|
|
23
|
+
monday: { min: 40, max: 60 },
|
|
24
|
+
tuesday: { min: 40, max: 60 },
|
|
25
|
+
wednesday: { min: 40, max: 60 },
|
|
26
|
+
thursday: { min: 40, max: 60 },
|
|
27
|
+
friday: { min: 70, max: 100 },
|
|
28
|
+
saturday: { min: 80, max: 120 },
|
|
29
|
+
sunday: { min: 50, max: 80 }
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
TIP_RATES = {
|
|
33
|
+
dine_in: { chance: 70, min: 15, max: 25 },
|
|
34
|
+
takeout: { chance: 20, min: 5, max: 15 },
|
|
35
|
+
delivery: { chance: 50, min: 10, max: 20 }
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
ITEMS_PER_PERIOD = {
|
|
39
|
+
breakfast: { min: 1, max: 3 },
|
|
40
|
+
lunch: { min: 2, max: 4 },
|
|
41
|
+
happy_hour: { min: 2, max: 4 },
|
|
42
|
+
dinner: { min: 3, max: 6 },
|
|
43
|
+
late_night: { min: 1, max: 3 }
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :config, :services, :data_loader, :refund_percentage
|
|
47
|
+
|
|
48
|
+
def initialize(config: nil, services: nil, refund_percentage: 5)
|
|
49
|
+
@config = config || RevelSandboxSimulator.configuration
|
|
50
|
+
@services = services || Services::Revel::ServicesManager.new(config: @config)
|
|
51
|
+
@data_loader = DataLoader.new(config: @config)
|
|
52
|
+
@refund_percentage = refund_percentage
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def generate_today(count: nil)
|
|
56
|
+
count ||= daily_order_count
|
|
57
|
+
RevelSandboxSimulator.logger.info("Generating #{count} orders for today...")
|
|
58
|
+
|
|
59
|
+
products = load_products
|
|
60
|
+
tenders = data_loader.load_tenders
|
|
61
|
+
|
|
62
|
+
period_counts = distribute_across_periods(count)
|
|
63
|
+
orders = []
|
|
64
|
+
|
|
65
|
+
period_counts.each do |period, period_count|
|
|
66
|
+
period_count.times do
|
|
67
|
+
order = generate_single_order(products, tenders, period: period)
|
|
68
|
+
orders << order if order
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
process_refunds(orders) if refund_percentage.positive?
|
|
73
|
+
generate_summary
|
|
74
|
+
|
|
75
|
+
RevelSandboxSimulator.logger.info("Generated #{orders.size} orders")
|
|
76
|
+
orders
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def generate_realistic_day(multiplier: 1.0)
|
|
80
|
+
count = (daily_order_count * multiplier).round
|
|
81
|
+
generate_today(count: count)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def generate_rush(period:, count: 15)
|
|
85
|
+
products = load_products
|
|
86
|
+
tenders = data_loader.load_tenders
|
|
87
|
+
|
|
88
|
+
count.times.filter_map do
|
|
89
|
+
generate_single_order(products, tenders, period: period.to_sym)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def daily_order_count
|
|
94
|
+
day = Date.today.strftime("%A").downcase.to_sym
|
|
95
|
+
pattern = ORDER_PATTERNS.fetch(day, ORDER_PATTERNS[:monday])
|
|
96
|
+
rand(pattern[:min]..pattern[:max])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def distribute_across_periods(total_count)
|
|
100
|
+
total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
|
|
101
|
+
MEAL_PERIODS.each_with_object({}) do |(period, data), hash|
|
|
102
|
+
hash[period] = ((data[:weight].to_f / total_weight) * total_count).round
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def load_products
|
|
109
|
+
data_loader.load_items
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def generate_single_order(products, tenders, period:)
|
|
113
|
+
dining_option = weighted_select(DINING_BY_PERIOD[period])
|
|
114
|
+
item_count_range = ITEMS_PER_PERIOD[period]
|
|
115
|
+
item_count = rand(item_count_range[:min]..item_count_range[:max])
|
|
116
|
+
selected_items = products.sample(item_count)
|
|
117
|
+
|
|
118
|
+
return nil if selected_items.empty?
|
|
119
|
+
|
|
120
|
+
subtotal = selected_items.sum { |i| i[:price] }
|
|
121
|
+
discount = calculate_discount(subtotal)
|
|
122
|
+
tax = calculate_tax(subtotal - discount)
|
|
123
|
+
tip = calculate_tip(subtotal, dining_option)
|
|
124
|
+
total = subtotal - discount + tax + tip
|
|
125
|
+
|
|
126
|
+
tender = select_tender(tenders)
|
|
127
|
+
payment_type = tender_to_payment_type(tender)
|
|
128
|
+
|
|
129
|
+
items_payload = selected_items.map do |item|
|
|
130
|
+
{ product_id: item[:sku] || item[:name], quantity: 1, unit_price: item[:price] }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
result = services.order.create_order(
|
|
134
|
+
items: items_payload,
|
|
135
|
+
dining_option: dining_option,
|
|
136
|
+
discount_amount: discount
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
order_id = result["id"]
|
|
140
|
+
|
|
141
|
+
services.payment.create_payment(
|
|
142
|
+
order_id: order_id,
|
|
143
|
+
amount: total,
|
|
144
|
+
payment_type: payment_type,
|
|
145
|
+
tip_amount: tip
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
persist_order(result, period, dining_option, subtotal, tax, tip, discount, total, tender, payment_type)
|
|
149
|
+
|
|
150
|
+
result
|
|
151
|
+
rescue ApiError => e
|
|
152
|
+
RevelSandboxSimulator.logger.warn("Order generation failed: #{e.message}")
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def persist_order(result, period, dining_option, subtotal, tax, tip, discount, total, tender, payment_type)
|
|
157
|
+
return unless Database.connected?
|
|
158
|
+
|
|
159
|
+
order = Models::SimulatedOrder.create!(
|
|
160
|
+
revel_order_id: result["id"] || SecureRandom.uuid,
|
|
161
|
+
status: "paid",
|
|
162
|
+
business_date: Date.today,
|
|
163
|
+
dining_option: dining_option.to_s,
|
|
164
|
+
meal_period: period.to_s,
|
|
165
|
+
subtotal: subtotal,
|
|
166
|
+
tax_amount: tax,
|
|
167
|
+
tip_amount: tip,
|
|
168
|
+
discount_amount: discount,
|
|
169
|
+
total: total,
|
|
170
|
+
metadata: { source: "simulator" }
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
Models::SimulatedPayment.create!(
|
|
174
|
+
simulated_order: order,
|
|
175
|
+
revel_payment_id: SecureRandom.uuid,
|
|
176
|
+
tender_name: tender || "Credit Card",
|
|
177
|
+
amount: total,
|
|
178
|
+
tip_amount: tip,
|
|
179
|
+
tax_amount: tax,
|
|
180
|
+
status: "success",
|
|
181
|
+
payment_type: payment_type.to_s
|
|
182
|
+
)
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def weighted_select(weights)
|
|
188
|
+
total = weights.values.sum
|
|
189
|
+
roll = rand(total)
|
|
190
|
+
cumulative = 0
|
|
191
|
+
|
|
192
|
+
weights.each do |key, weight|
|
|
193
|
+
cumulative += weight
|
|
194
|
+
return key if roll < cumulative
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
weights.keys.last
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def select_tender(tenders)
|
|
201
|
+
return "Credit Card" if tenders.nil? || tenders.empty?
|
|
202
|
+
|
|
203
|
+
weights = tenders.to_h { |t| [t["name"], t["weight"] || 1] }
|
|
204
|
+
weighted_select(weights)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def tender_to_payment_type(tender_name)
|
|
208
|
+
case tender_name&.downcase
|
|
209
|
+
when "cash" then :cash
|
|
210
|
+
when "debit card" then :debit_card
|
|
211
|
+
when "gift card" then :gift_card
|
|
212
|
+
when "check" then :check
|
|
213
|
+
when "mobile pay" then :mobile_pay
|
|
214
|
+
else :credit_card
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def calculate_discount(subtotal)
|
|
219
|
+
return 0 if rand(100) >= 8
|
|
220
|
+
|
|
221
|
+
percentage = rand(10..20)
|
|
222
|
+
(subtotal * percentage / 100.0).round
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def calculate_tip(subtotal, dining_option)
|
|
226
|
+
rates = TIP_RATES.fetch(dining_option.to_sym, TIP_RATES[:dine_in])
|
|
227
|
+
return 0 if rand(100) >= rates[:chance]
|
|
228
|
+
|
|
229
|
+
percentage = rand(rates[:min]..rates[:max])
|
|
230
|
+
(subtotal * percentage / 100.0).round
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def calculate_tax(taxable_amount)
|
|
234
|
+
(taxable_amount * config.tax_rate / 100.0).round
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def process_refunds(orders)
|
|
238
|
+
return if orders.empty?
|
|
239
|
+
|
|
240
|
+
refund_count = (orders.size * refund_percentage / 100.0).ceil
|
|
241
|
+
orders.sample(refund_count).each do |order|
|
|
242
|
+
next unless Database.connected?
|
|
243
|
+
|
|
244
|
+
simulated = Models::SimulatedOrder.find_by(revel_order_id: order["id"])
|
|
245
|
+
simulated&.update!(status: "refunded")
|
|
246
|
+
end
|
|
247
|
+
rescue StandardError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def generate_summary
|
|
252
|
+
return unless Database.connected?
|
|
253
|
+
|
|
254
|
+
Models::DailySummary.generate_for!(Date.today)
|
|
255
|
+
rescue StandardError
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class ApiRequest < Record
|
|
6
|
+
scope :errors, -> { where.not(error_message: nil) }
|
|
7
|
+
scope :by_resource, ->(type) { where(resource_type: type) }
|
|
8
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class BusinessType < Record
|
|
6
|
+
has_many :categories, dependent: :destroy
|
|
7
|
+
has_many :items, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
validates :key, presence: true, uniqueness: true
|
|
10
|
+
validates :name, presence: true
|
|
11
|
+
validates :industry, presence: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class Category < Record
|
|
6
|
+
belongs_to :business_type
|
|
7
|
+
has_many :items, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true, uniqueness: { scope: :business_type_id }
|
|
10
|
+
validates :sort_order, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
11
|
+
|
|
12
|
+
scope :for_business_type, ->(bt) { where(business_type: bt) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class DailySummary < Record
|
|
6
|
+
validates :summary_date, presence: true, uniqueness: true
|
|
7
|
+
|
|
8
|
+
def self.generate_for!(date)
|
|
9
|
+
orders = SimulatedOrder.for_date(date)
|
|
10
|
+
payments = SimulatedPayment.where(simulated_order: orders).successful
|
|
11
|
+
|
|
12
|
+
breakdown = {
|
|
13
|
+
by_meal_period: build_meal_period_breakdown(orders),
|
|
14
|
+
by_dining_option: build_dining_option_breakdown(orders),
|
|
15
|
+
by_tender: build_tender_breakdown(payments)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
find_or_initialize_by(summary_date: date).tap do |summary|
|
|
19
|
+
summary.update!(
|
|
20
|
+
order_count: orders.count,
|
|
21
|
+
payment_count: payments.count,
|
|
22
|
+
refund_count: orders.refunded.count,
|
|
23
|
+
total_revenue: orders.sum(:total),
|
|
24
|
+
total_tax: orders.sum(:tax_amount),
|
|
25
|
+
total_tips: orders.sum(:tip_amount),
|
|
26
|
+
total_discounts: orders.sum(:discount_amount),
|
|
27
|
+
breakdown: breakdown
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.build_meal_period_breakdown(orders)
|
|
33
|
+
%w[breakfast lunch happy_hour dinner late_night].each_with_object({}) do |period, hash|
|
|
34
|
+
period_orders = orders.for_meal_period(period)
|
|
35
|
+
hash[period] = { count: period_orders.count, revenue: period_orders.sum(:total) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.build_dining_option_breakdown(orders)
|
|
40
|
+
%w[dine_in takeout delivery].each_with_object({}) do |option, hash|
|
|
41
|
+
option_orders = orders.where(dining_option: option)
|
|
42
|
+
hash[option] = { count: option_orders.count, revenue: option_orders.sum(:total) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.build_tender_breakdown(payments)
|
|
47
|
+
payments.group(:tender_name).sum(:amount).transform_values(&:to_i)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private_class_method :build_meal_period_breakdown, :build_dining_option_breakdown, :build_tender_breakdown
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class Item < Record
|
|
6
|
+
belongs_to :business_type
|
|
7
|
+
belongs_to :category, optional: true
|
|
8
|
+
|
|
9
|
+
validates :name, presence: true
|
|
10
|
+
validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
11
|
+
validates :sku, uniqueness: true, allow_nil: true
|
|
12
|
+
|
|
13
|
+
scope :for_business_type, ->(bt) { where(business_type: bt) }
|
|
14
|
+
scope :for_category, ->(cat) { where(category: cat) }
|
|
15
|
+
scope :by_price, -> { order(:price) }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class SimulatedOrder < Record
|
|
6
|
+
has_many :simulated_payments, dependent: :destroy
|
|
7
|
+
|
|
8
|
+
validates :revel_order_id, presence: true, uniqueness: true
|
|
9
|
+
validates :status, presence: true, inclusion: { in: %w[open paid refunded voided] }
|
|
10
|
+
validates :dining_option, presence: true, inclusion: { in: %w[dine_in takeout delivery] }
|
|
11
|
+
validates :meal_period, presence: true,
|
|
12
|
+
inclusion: { in: %w[breakfast lunch happy_hour dinner late_night] }
|
|
13
|
+
|
|
14
|
+
scope :paid, -> { where(status: "paid") }
|
|
15
|
+
scope :refunded, -> { where(status: "refunded") }
|
|
16
|
+
scope :for_date, ->(date) { where(business_date: date) }
|
|
17
|
+
scope :for_meal_period, ->(period) { where(meal_period: period) }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class SimulatedPayment < Record
|
|
6
|
+
belongs_to :simulated_order
|
|
7
|
+
|
|
8
|
+
validates :simulated_order, presence: true
|
|
9
|
+
validates :tender_name, presence: true
|
|
10
|
+
validates :amount, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
11
|
+
validates :status, presence: true, inclusion: { in: %w[pending success failed] }
|
|
12
|
+
|
|
13
|
+
scope :successful, -> { where(status: "success") }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RevelSandboxSimulator
|
|
4
|
+
class Seeder
|
|
5
|
+
SEED_MAP = {
|
|
6
|
+
restaurant: {
|
|
7
|
+
industry: "food",
|
|
8
|
+
categories: {
|
|
9
|
+
"Appetizers" => ["Buffalo Wings", "Mozzarella Sticks", "Calamari", "Bruschetta", "Spinach Dip"],
|
|
10
|
+
"Entrees" => ["NY Strip Steak", "Grilled Salmon", "Chicken Parmesan", "Lamb Chops", "Shrimp Pasta"],
|
|
11
|
+
"Sides" => ["Caesar Salad", "Mashed Potatoes", "Grilled Vegetables", "French Fries", "Coleslaw"],
|
|
12
|
+
"Beverages" => ["Soft Drink", "Iced Tea", "Lemonade", "Coffee", "Sparkling Water"],
|
|
13
|
+
"Desserts" => ["Chocolate Cake", "Tiramisu", "Cheesecake", "Creme Brulee", "Apple Pie"]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
cafe_bakery: {
|
|
17
|
+
industry: "food",
|
|
18
|
+
categories: {
|
|
19
|
+
"Hot Drinks" => ["Espresso", "Cappuccino", "Latte", "Americano", "Hot Chocolate"],
|
|
20
|
+
"Cold Drinks" => ["Iced Coffee", "Cold Brew", "Iced Latte", "Smoothie", "Lemonade"],
|
|
21
|
+
"Pastries" => ["Croissant", "Muffin", "Scone", "Danish", "Cinnamon Roll"],
|
|
22
|
+
"Sandwiches" => ["Avocado Toast", "BLT", "Turkey Club", "Grilled Cheese", "Veggie Wrap"],
|
|
23
|
+
"Cakes" => ["Chocolate Cake", "Red Velvet", "Carrot Cake", "Lemon Tart", "Brownie"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
bar_nightclub: {
|
|
27
|
+
industry: "food",
|
|
28
|
+
categories: {
|
|
29
|
+
"Draft Beer" => ["IPA Pint", "Lager Pint", "Stout Pint", "Wheat Beer", "Pale Ale"],
|
|
30
|
+
"Cocktails" => ["Margarita", "Old Fashioned", "Mojito", "Cosmopolitan", "Manhattan"],
|
|
31
|
+
"Wine" => ["House Red Wine", "House White Wine", "Rose", "Prosecco", "Pinot Noir"],
|
|
32
|
+
"Spirits" => ["Whiskey Shot", "Vodka Shot", "Tequila Shot", "Rum Shot", "Gin Shot"],
|
|
33
|
+
"Bar Food" => ["Loaded Nachos", "Chicken Wings", "Sliders", "Fries Basket", "Pretzel Bites"]
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
retail_general: {
|
|
37
|
+
industry: "retail",
|
|
38
|
+
categories: {
|
|
39
|
+
"Electronics" => ["Phone Charger", "USB Cable", "Earbuds", "Power Bank", "Screen Protector"],
|
|
40
|
+
"Clothing" => %w[T-Shirt Hoodie Cap Socks Belt],
|
|
41
|
+
"Home & Garden" => ["Candle", "Mug", "Plant Pot", "Picture Frame", "Cushion"],
|
|
42
|
+
"Health & Beauty" => ["Hand Cream", "Lip Balm", "Face Mask", "Shampoo", "Body Wash"],
|
|
43
|
+
"Groceries" => ["Snack Bar", "Energy Drink", "Water Bottle", "Trail Mix", "Protein Bar"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
def seed!(business_type: nil)
|
|
49
|
+
types_to_seed = business_type ? [business_type.to_sym] : SEED_MAP.keys
|
|
50
|
+
counts = { business_types: 0, categories: 0, items: 0 }
|
|
51
|
+
|
|
52
|
+
types_to_seed.each do |type_key|
|
|
53
|
+
data = SEED_MAP[type_key]
|
|
54
|
+
next unless data
|
|
55
|
+
|
|
56
|
+
bt = seed_business_type(type_key, data)
|
|
57
|
+
counts[:business_types] += 1
|
|
58
|
+
|
|
59
|
+
data[:categories].each_with_index do |(cat_name, item_names), idx|
|
|
60
|
+
cat = seed_category(bt, cat_name, idx)
|
|
61
|
+
counts[:categories] += 1
|
|
62
|
+
|
|
63
|
+
item_names.each do |item_name|
|
|
64
|
+
seed_item(bt, cat, type_key, item_name)
|
|
65
|
+
counts[:items] += 1
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
RevelSandboxSimulator.logger.info("Seeded: #{counts}")
|
|
71
|
+
counts
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def seed_business_type(key, data)
|
|
77
|
+
Models::BusinessType.find_or_create_by!(key: key.to_s) do |bt|
|
|
78
|
+
bt.name = key.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
|
|
79
|
+
bt.industry = data[:industry]
|
|
80
|
+
bt.order_profile = {}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def seed_category(business_type, name, sort_order)
|
|
85
|
+
Models::Category.find_or_create_by!(business_type: business_type, name: name) do |cat|
|
|
86
|
+
cat.sort_order = sort_order
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def seed_item(business_type, category, type_key, item_name)
|
|
91
|
+
loader = Generators::DataLoader.new(config: build_config(type_key))
|
|
92
|
+
items_json = loader.load_items
|
|
93
|
+
json_item = items_json.find { |i| i[:name] == item_name }
|
|
94
|
+
price = json_item ? json_item[:price] : rand(299..2999)
|
|
95
|
+
|
|
96
|
+
Models::Item.find_or_create_by!(business_type: business_type, name: item_name) do |item|
|
|
97
|
+
item.category = category
|
|
98
|
+
item.price = price
|
|
99
|
+
item.sku = generate_sku(type_key, item_name)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def generate_sku(type_key, item_name)
|
|
104
|
+
prefix = type_key.to_s[0..2].upcase
|
|
105
|
+
suffix = item_name.gsub(/[^a-zA-Z0-9]/, "")[0..5].upcase
|
|
106
|
+
"#{prefix}-#{suffix}-#{SecureRandom.hex(2).upcase}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_config(type_key)
|
|
110
|
+
config = Configuration.new
|
|
111
|
+
config.business_type = type_key
|
|
112
|
+
config
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rest-client"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module RevelSandboxSimulator
|
|
8
|
+
module Services
|
|
9
|
+
class BaseService
|
|
10
|
+
DEFAULT_LIMIT = 50
|
|
11
|
+
MAX_LIMIT = 200
|
|
12
|
+
|
|
13
|
+
attr_reader :config
|
|
14
|
+
|
|
15
|
+
def initialize(config: nil)
|
|
16
|
+
@config = config || RevelSandboxSimulator.configuration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
protected
|
|
20
|
+
|
|
21
|
+
def request(method, path, payload: nil, params: {}, resource_type: nil, resource_id: nil)
|
|
22
|
+
url = build_url(path, params)
|
|
23
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
|
|
25
|
+
response = execute_request(method, url, payload)
|
|
26
|
+
parsed = parse_response(response)
|
|
27
|
+
|
|
28
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
29
|
+
audit_api_request(method, url, payload, response.code, parsed, duration, nil, resource_type, resource_id)
|
|
30
|
+
|
|
31
|
+
parsed
|
|
32
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
33
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
|
|
34
|
+
error_body = parse_response(e.response)
|
|
35
|
+
error_message = extract_error_message(e, error_body)
|
|
36
|
+
audit_api_request(method, url, payload, e.http_code, error_body, duration, error_message, resource_type, resource_id)
|
|
37
|
+
raise ApiError, "#{method.upcase} #{path} failed (#{e.http_code}): #{error_message}"
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
raise ApiError, "#{method.upcase} #{path} failed: #{e.message}" unless e.is_a?(ApiError)
|
|
40
|
+
|
|
41
|
+
raise
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_all_pages(path, params: {})
|
|
45
|
+
all_objects = []
|
|
46
|
+
offset = 0
|
|
47
|
+
limit = params.delete(:limit) || DEFAULT_LIMIT
|
|
48
|
+
|
|
49
|
+
loop do
|
|
50
|
+
page_params = params.merge(offset: offset, limit: limit)
|
|
51
|
+
result = request(:get, path, params: page_params)
|
|
52
|
+
|
|
53
|
+
objects = result["objects"] || []
|
|
54
|
+
all_objects.concat(objects)
|
|
55
|
+
|
|
56
|
+
meta = result["meta"] || {}
|
|
57
|
+
break if meta["next"].nil? || objects.empty?
|
|
58
|
+
|
|
59
|
+
offset += limit
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
all_objects
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_url(path, params = {})
|
|
66
|
+
url = "#{config.base_url}/#{path}"
|
|
67
|
+
url += "?#{URI.encode_www_form(params)}" unless params.empty?
|
|
68
|
+
url
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def execute_request(method, url, payload)
|
|
72
|
+
args = {
|
|
73
|
+
method: method,
|
|
74
|
+
url: url,
|
|
75
|
+
headers: request_headers(payload)
|
|
76
|
+
}
|
|
77
|
+
args[:payload] = payload.to_json if payload
|
|
78
|
+
|
|
79
|
+
RestClient::Request.execute(**args)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def request_headers(payload = nil)
|
|
83
|
+
headers = {
|
|
84
|
+
"API-AUTHENTICATION" => config.auth_header,
|
|
85
|
+
accept: :json
|
|
86
|
+
}
|
|
87
|
+
headers[:content_type] = :json if payload
|
|
88
|
+
headers
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_response(response)
|
|
92
|
+
return {} if response.nil? || response.body.nil? || response.body.empty?
|
|
93
|
+
|
|
94
|
+
JSON.parse(response.body)
|
|
95
|
+
rescue JSON::ParserError
|
|
96
|
+
{ "raw" => response.body }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def extract_error_message(error, parsed_body)
|
|
100
|
+
if parsed_body.is_a?(Hash)
|
|
101
|
+
parsed_body["error"] || parsed_body["detail"] || parsed_body["message"] || error.message
|
|
102
|
+
else
|
|
103
|
+
error.message
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def audit_api_request(method, url, request_payload, status, response_payload, duration, error, resource_type, resource_id)
|
|
108
|
+
return unless Database.connected?
|
|
109
|
+
|
|
110
|
+
Models::ApiRequest.create!(
|
|
111
|
+
http_method: method.to_s.upcase,
|
|
112
|
+
url: url,
|
|
113
|
+
request_payload: request_payload,
|
|
114
|
+
response_status: status,
|
|
115
|
+
response_payload: response_payload,
|
|
116
|
+
duration_ms: duration,
|
|
117
|
+
error_message: error,
|
|
118
|
+
resource_type: resource_type,
|
|
119
|
+
resource_id: resource_id&.to_s
|
|
120
|
+
)
|
|
121
|
+
rescue StandardError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def with_api_fallback(fallback: nil, log_level: :warn)
|
|
126
|
+
yield
|
|
127
|
+
rescue ApiError => e
|
|
128
|
+
RevelSandboxSimulator.logger.send(log_level, e.message)
|
|
129
|
+
fallback
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def safe_dig(hash, *keys, default: nil)
|
|
133
|
+
result = hash
|
|
134
|
+
keys.each do |key|
|
|
135
|
+
return default unless result.is_a?(Hash)
|
|
136
|
+
|
|
137
|
+
result = result[key]
|
|
138
|
+
end
|
|
139
|
+
result || default
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|