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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +305 -0
  4. data/bin/simulate +187 -0
  5. data/lib/revel_sandbox_simulator/configuration.rb +109 -0
  6. data/lib/revel_sandbox_simulator/data/bar_nightclub/categories.json +7 -0
  7. data/lib/revel_sandbox_simulator/data/bar_nightclub/items.json +27 -0
  8. data/lib/revel_sandbox_simulator/data/bar_nightclub/tenders.json +7 -0
  9. data/lib/revel_sandbox_simulator/data/cafe_bakery/categories.json +7 -0
  10. data/lib/revel_sandbox_simulator/data/cafe_bakery/items.json +27 -0
  11. data/lib/revel_sandbox_simulator/data/cafe_bakery/tenders.json +7 -0
  12. data/lib/revel_sandbox_simulator/data/restaurant/categories.json +7 -0
  13. data/lib/revel_sandbox_simulator/data/restaurant/items.json +27 -0
  14. data/lib/revel_sandbox_simulator/data/restaurant/tenders.json +7 -0
  15. data/lib/revel_sandbox_simulator/data/retail_general/categories.json +7 -0
  16. data/lib/revel_sandbox_simulator/data/retail_general/items.json +27 -0
  17. data/lib/revel_sandbox_simulator/data/retail_general/tenders.json +7 -0
  18. data/lib/revel_sandbox_simulator/database.rb +92 -0
  19. data/lib/revel_sandbox_simulator/db/factories/api_requests.rb +15 -0
  20. data/lib/revel_sandbox_simulator/db/factories/business_types.rb +34 -0
  21. data/lib/revel_sandbox_simulator/db/factories/categories.rb +10 -0
  22. data/lib/revel_sandbox_simulator/db/factories/items.rb +12 -0
  23. data/lib/revel_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
  24. data/lib/revel_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
  25. data/lib/revel_sandbox_simulator/db/migrate/20260313000001_enable_pgcrypto.rb +7 -0
  26. data/lib/revel_sandbox_simulator/db/migrate/20260313000002_create_business_types.rb +16 -0
  27. data/lib/revel_sandbox_simulator/db/migrate/20260313000003_create_categories.rb +16 -0
  28. data/lib/revel_sandbox_simulator/db/migrate/20260313000004_create_items.rb +19 -0
  29. data/lib/revel_sandbox_simulator/db/migrate/20260313000005_create_simulated_orders.rb +27 -0
  30. data/lib/revel_sandbox_simulator/db/migrate/20260313000006_create_simulated_payments.rb +22 -0
  31. data/lib/revel_sandbox_simulator/db/migrate/20260313000007_create_api_requests.rb +22 -0
  32. data/lib/revel_sandbox_simulator/db/migrate/20260313000008_create_daily_summaries.rb +21 -0
  33. data/lib/revel_sandbox_simulator/generators/data_loader.rb +80 -0
  34. data/lib/revel_sandbox_simulator/generators/entity_generator.rb +61 -0
  35. data/lib/revel_sandbox_simulator/generators/order_generator.rb +260 -0
  36. data/lib/revel_sandbox_simulator/models/api_request.rb +11 -0
  37. data/lib/revel_sandbox_simulator/models/business_type.rb +14 -0
  38. data/lib/revel_sandbox_simulator/models/category.rb +15 -0
  39. data/lib/revel_sandbox_simulator/models/daily_summary.rb +53 -0
  40. data/lib/revel_sandbox_simulator/models/item.rb +18 -0
  41. data/lib/revel_sandbox_simulator/models/record.rb +9 -0
  42. data/lib/revel_sandbox_simulator/models/simulated_order.rb +20 -0
  43. data/lib/revel_sandbox_simulator/models/simulated_payment.rb +16 -0
  44. data/lib/revel_sandbox_simulator/seeder.rb +115 -0
  45. data/lib/revel_sandbox_simulator/services/base_service.rb +143 -0
  46. data/lib/revel_sandbox_simulator/services/revel/customer_service.rb +53 -0
  47. data/lib/revel_sandbox_simulator/services/revel/establishment_service.rb +19 -0
  48. data/lib/revel_sandbox_simulator/services/revel/order_service.rb +77 -0
  49. data/lib/revel_sandbox_simulator/services/revel/payment_service.rb +52 -0
  50. data/lib/revel_sandbox_simulator/services/revel/product_service.rb +87 -0
  51. data/lib/revel_sandbox_simulator/services/revel/services_manager.rb +45 -0
  52. data/lib/revel_sandbox_simulator/version.rb +5 -0
  53. data/lib/revel_sandbox_simulator.rb +38 -0
  54. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RevelSandboxSimulator
4
+ module Models
5
+ class Record < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ end
9
+ 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