lightspeed_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 +5 -0
- data/LICENSE +21 -0
- data/README.md +215 -0
- data/bin/simulate +162 -0
- data/lib/lightspeed_sandbox_simulator/configuration.rb +151 -0
- data/lib/lightspeed_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/lightspeed_sandbox_simulator/data/bar_nightclub/items.json +26 -0
- data/lib/lightspeed_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
- data/lib/lightspeed_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/lightspeed_sandbox_simulator/data/cafe_bakery/items.json +28 -0
- data/lib/lightspeed_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
- data/lib/lightspeed_sandbox_simulator/data/restaurant/categories.json +9 -0
- data/lib/lightspeed_sandbox_simulator/data/restaurant/items.json +29 -0
- data/lib/lightspeed_sandbox_simulator/data/restaurant/tenders.json +9 -0
- data/lib/lightspeed_sandbox_simulator/data/retail_general/categories.json +9 -0
- data/lib/lightspeed_sandbox_simulator/data/retail_general/items.json +17 -0
- data/lib/lightspeed_sandbox_simulator/data/retail_general/tenders.json +8 -0
- data/lib/lightspeed_sandbox_simulator/database.rb +116 -0
- data/lib/lightspeed_sandbox_simulator/db/factories/api_requests.rb +10 -0
- data/lib/lightspeed_sandbox_simulator/db/factories/business_types.rb +9 -0
- data/lib/lightspeed_sandbox_simulator/db/factories/categories.rb +9 -0
- data/lib/lightspeed_sandbox_simulator/db/factories/items.rb +11 -0
- data/lib/lightspeed_sandbox_simulator/db/factories/simulated_orders.rb +17 -0
- data/lib/lightspeed_sandbox_simulator/db/factories/simulated_payments.rb +13 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000001_enable_pgcrypto.rb +7 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000002_create_business_types.rb +14 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000003_create_categories.rb +13 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000004_create_items.rb +14 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000005_create_simulated_orders.rb +23 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000006_create_simulated_payments.rb +16 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000007_create_api_requests.rb +18 -0
- data/lib/lightspeed_sandbox_simulator/db/migrate/20260313000008_create_daily_summaries.rb +20 -0
- data/lib/lightspeed_sandbox_simulator/generators/data_loader.rb +75 -0
- data/lib/lightspeed_sandbox_simulator/generators/entity_generator.rb +96 -0
- data/lib/lightspeed_sandbox_simulator/generators/order_generator.rb +293 -0
- data/lib/lightspeed_sandbox_simulator/models/api_request.rb +9 -0
- data/lib/lightspeed_sandbox_simulator/models/business_type.rb +12 -0
- data/lib/lightspeed_sandbox_simulator/models/category.rb +12 -0
- data/lib/lightspeed_sandbox_simulator/models/daily_summary.rb +44 -0
- data/lib/lightspeed_sandbox_simulator/models/item.rb +11 -0
- data/lib/lightspeed_sandbox_simulator/models/simulated_order.rb +15 -0
- data/lib/lightspeed_sandbox_simulator/models/simulated_payment.rb +13 -0
- data/lib/lightspeed_sandbox_simulator/seeder.rb +79 -0
- data/lib/lightspeed_sandbox_simulator/services/base_service.rb +158 -0
- data/lib/lightspeed_sandbox_simulator/services/lightspeed/business_service.rb +21 -0
- data/lib/lightspeed_sandbox_simulator/services/lightspeed/menu_service.rb +70 -0
- data/lib/lightspeed_sandbox_simulator/services/lightspeed/order_service.rb +74 -0
- data/lib/lightspeed_sandbox_simulator/services/lightspeed/payment_method_service.rb +30 -0
- data/lib/lightspeed_sandbox_simulator/services/lightspeed/payment_service.rb +55 -0
- data/lib/lightspeed_sandbox_simulator/services/lightspeed/services_manager.rb +54 -0
- data/lib/lightspeed_sandbox_simulator.rb +30 -0
- metadata +332 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LightspeedSandboxSimulator
|
|
4
|
+
module Generators
|
|
5
|
+
class OrderGenerator
|
|
6
|
+
MEAL_PERIODS = {
|
|
7
|
+
breakfast: { weight: 15, items_range: 1..3 },
|
|
8
|
+
lunch: { weight: 30, items_range: 2..4 },
|
|
9
|
+
happy_hour: { weight: 10, items_range: 2..4 },
|
|
10
|
+
dinner: { weight: 35, items_range: 3..6 },
|
|
11
|
+
late_night: { weight: 10, items_range: 1..3 }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
DINING_OPTIONS = {
|
|
15
|
+
breakfast: { eat_in: 40, takeaway: 50, delivery: 10 },
|
|
16
|
+
lunch: { eat_in: 35, takeaway: 45, delivery: 20 },
|
|
17
|
+
happy_hour: { eat_in: 80, takeaway: 15, delivery: 5 },
|
|
18
|
+
dinner: { eat_in: 70, takeaway: 15, delivery: 15 },
|
|
19
|
+
late_night: { eat_in: 50, takeaway: 30, delivery: 20 }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
DAY_RANGES = {
|
|
23
|
+
0 => 50..80, # Sunday
|
|
24
|
+
1 => 40..60, # Monday
|
|
25
|
+
2 => 40..60, # Tuesday
|
|
26
|
+
3 => 40..60, # Wednesday
|
|
27
|
+
4 => 40..60, # Thursday
|
|
28
|
+
5 => 70..100, # Friday
|
|
29
|
+
6 => 80..120 # Saturday
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def initialize(config: nil, business_type: :restaurant, refund_percentage: 5)
|
|
33
|
+
@config = config || LightspeedSandboxSimulator.configuration
|
|
34
|
+
@business_type = business_type
|
|
35
|
+
@refund_percentage = refund_percentage
|
|
36
|
+
@manager = Services::Lightspeed::ServicesManager.new(config: @config)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def generate_today(count: nil)
|
|
40
|
+
data = fetch_required_data
|
|
41
|
+
count ||= daily_order_count
|
|
42
|
+
orders = []
|
|
43
|
+
|
|
44
|
+
distribution = distribute_across_periods(count)
|
|
45
|
+
distribution.each do |period, period_count|
|
|
46
|
+
period_count.times do
|
|
47
|
+
order = generate_single_order(data, period: period)
|
|
48
|
+
orders << order if order
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
process_refunds(orders) if @refund_percentage.positive?
|
|
53
|
+
generate_summary
|
|
54
|
+
|
|
55
|
+
orders
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def generate_realistic_day(multiplier: 1.0)
|
|
59
|
+
count = (daily_order_count * multiplier).round
|
|
60
|
+
generate_today(count: count)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def generate_rush(period: :dinner, count: 10)
|
|
64
|
+
data = fetch_required_data
|
|
65
|
+
orders = []
|
|
66
|
+
|
|
67
|
+
count.times do
|
|
68
|
+
order = generate_single_order(data, period: period)
|
|
69
|
+
orders << order if order
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
orders
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def fetch_required_data
|
|
78
|
+
items = @manager.menu.list_items
|
|
79
|
+
items = items['items'] if items.is_a?(Hash) && items.key?('items')
|
|
80
|
+
items = Array(items)
|
|
81
|
+
raise Error, 'No items found. Run setup first.' if items.empty?
|
|
82
|
+
|
|
83
|
+
payment_methods = @manager.payment_methods.list_payment_methods
|
|
84
|
+
payment_methods = payment_methods['paymentMethods'] if payment_methods.is_a?(Hash)
|
|
85
|
+
payment_methods = Array(payment_methods)
|
|
86
|
+
raise Error, 'No payment methods found. Run setup first.' if payment_methods.empty?
|
|
87
|
+
|
|
88
|
+
tenders_config = DataLoader.new(business_type: @business_type).load_tenders
|
|
89
|
+
{ items: items, payment_methods: payment_methods, tenders_config: tenders_config }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def generate_single_order(data, period: :lunch)
|
|
93
|
+
return nil if data[:items].empty?
|
|
94
|
+
|
|
95
|
+
dining_option = select_dining_option(period)
|
|
96
|
+
order_items = select_items(data[:items], period)
|
|
97
|
+
subtotal = order_items.sum { |i| (i[:unit_price] || 0) * (i[:quantity] || 1) }
|
|
98
|
+
tax = (subtotal * @config.tax_rate / 100.0).round(2)
|
|
99
|
+
discount = calculate_discount(subtotal)
|
|
100
|
+
tip = calculate_tip(subtotal, dining_option)
|
|
101
|
+
total = subtotal + tax - discount + tip
|
|
102
|
+
|
|
103
|
+
order_type = dining_option == :eat_in ? :local : :to_go
|
|
104
|
+
result = create_order(order_type, order_items)
|
|
105
|
+
return nil unless result
|
|
106
|
+
|
|
107
|
+
payment_method = select_payment_method(data[:payment_methods], data[:tenders_config])
|
|
108
|
+
create_payment(result, total, tip, payment_method)
|
|
109
|
+
persist_order(result, period, dining_option, tax, tip, discount, total, payment_method)
|
|
110
|
+
|
|
111
|
+
result
|
|
112
|
+
rescue ApiError => e
|
|
113
|
+
LightspeedSandboxSimulator.logger.warn("Order failed: #{e.message}")
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def create_order(order_type, items)
|
|
118
|
+
if order_type == :local
|
|
119
|
+
@manager.orders.create_local_order(items: items, table_number: rand(1..20))
|
|
120
|
+
else
|
|
121
|
+
@manager.orders.create_to_go_order(items: items)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def create_payment(order_result, total, tip, payment_method)
|
|
126
|
+
order_id = order_result['id'] || order_result['orderId']
|
|
127
|
+
return unless order_id
|
|
128
|
+
|
|
129
|
+
method_id = payment_method['id']
|
|
130
|
+
return unless method_id
|
|
131
|
+
|
|
132
|
+
@manager.payments.create_payment(
|
|
133
|
+
order_id: order_id,
|
|
134
|
+
amount: total,
|
|
135
|
+
tip_amount: tip,
|
|
136
|
+
payment_method_id: method_id
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def select_items(items, period)
|
|
141
|
+
config = MEAL_PERIODS[period] || MEAL_PERIODS[:lunch]
|
|
142
|
+
count = rand(config[:items_range])
|
|
143
|
+
items.sample(count).map do |item|
|
|
144
|
+
{ item_id: item['id'], quantity: 1, unit_price: item['price'].to_f }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def select_dining_option(period)
|
|
149
|
+
weights = DINING_OPTIONS[period] || DINING_OPTIONS[:lunch]
|
|
150
|
+
weighted_select(weights)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def select_payment_method(payment_methods, tenders_config)
|
|
154
|
+
return payment_methods.first if tenders_config.empty?
|
|
155
|
+
|
|
156
|
+
selected_name = weighted_select_tender(tenders_config)
|
|
157
|
+
matched = payment_methods.find { |m| m['name']&.downcase == selected_name.downcase }
|
|
158
|
+
matched || payment_methods.first
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def weighted_select_tender(tenders_config)
|
|
162
|
+
total = tenders_config.sum { |t| t['weight'].to_i }
|
|
163
|
+
return tenders_config.first['name'] if total.zero?
|
|
164
|
+
|
|
165
|
+
roll = rand(total)
|
|
166
|
+
cumulative = 0
|
|
167
|
+
tenders_config.each do |tender|
|
|
168
|
+
cumulative += tender['weight'].to_i
|
|
169
|
+
return tender['name'] if roll < cumulative
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# :nocov:
|
|
173
|
+
tenders_config.last&.dig('name')
|
|
174
|
+
# :nocov:
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def weighted_select(weights)
|
|
178
|
+
total = weights.values.sum
|
|
179
|
+
return weights.keys.last if total.zero?
|
|
180
|
+
|
|
181
|
+
roll = rand(total)
|
|
182
|
+
cumulative = 0
|
|
183
|
+
weights.each do |key, weight|
|
|
184
|
+
cumulative += weight
|
|
185
|
+
return key if roll < cumulative
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# :nocov:
|
|
189
|
+
weights.keys.last
|
|
190
|
+
# :nocov:
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def calculate_discount(subtotal)
|
|
194
|
+
return 0.0 if rand(100) >= 8
|
|
195
|
+
|
|
196
|
+
percentage = rand(10..20) / 100.0
|
|
197
|
+
(subtotal * percentage).round(2)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def calculate_tip(subtotal, dining_option)
|
|
201
|
+
tip_config = case dining_option
|
|
202
|
+
when :eat_in then { chance: 70, min: 15, max: 25 }
|
|
203
|
+
when :takeaway then { chance: 20, min: 5, max: 15 }
|
|
204
|
+
when :delivery then { chance: 50, min: 10, max: 20 }
|
|
205
|
+
else { chance: 30, min: 10, max: 20 }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
return 0.0 if rand(100) >= tip_config[:chance]
|
|
209
|
+
|
|
210
|
+
percentage = rand(tip_config[:min]..tip_config[:max]) / 100.0
|
|
211
|
+
(subtotal * percentage).round(2)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def distribute_across_periods(total)
|
|
215
|
+
distribution = {}
|
|
216
|
+
|
|
217
|
+
MEAL_PERIODS.each do |period, config|
|
|
218
|
+
count = (total * config[:weight] / 100.0).round
|
|
219
|
+
distribution[period] = count
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
diff = total - distribution.values.sum
|
|
223
|
+
distribution[:dinner] += diff
|
|
224
|
+
|
|
225
|
+
distribution
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def daily_order_count
|
|
229
|
+
range = DAY_RANGES[Date.today.wday] || (40..60)
|
|
230
|
+
rand(range)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def process_refunds(orders)
|
|
234
|
+
return if orders.empty?
|
|
235
|
+
|
|
236
|
+
refund_count = (orders.size * @refund_percentage / 100.0).ceil
|
|
237
|
+
return if refund_count.zero?
|
|
238
|
+
|
|
239
|
+
orders.sample(refund_count).each do |order|
|
|
240
|
+
order_id = order['id']
|
|
241
|
+
next unless order_id
|
|
242
|
+
|
|
243
|
+
mark_order_refunded(order_id)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def mark_order_refunded(order_id)
|
|
248
|
+
return unless Database.connected?
|
|
249
|
+
|
|
250
|
+
record = Models::SimulatedOrder.find_by(order_id: order_id.to_s)
|
|
251
|
+
record&.update!(status: 'refunded')
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
LightspeedSandboxSimulator.logger.debug("Refund record failed: #{e.message}")
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def persist_order(result, period, dining_option, tax, tip, discount, total, payment_method)
|
|
257
|
+
return unless Database.connected?
|
|
258
|
+
|
|
259
|
+
order = Models::SimulatedOrder.create!(
|
|
260
|
+
order_id: (result['id'] || result['orderId']).to_s,
|
|
261
|
+
order_type: result['orderType'] || 'local',
|
|
262
|
+
meal_period: period.to_s,
|
|
263
|
+
dining_option: dining_option.to_s,
|
|
264
|
+
order_date: Date.today,
|
|
265
|
+
total: (total * 100).round,
|
|
266
|
+
tax_amount: (tax * 100).round,
|
|
267
|
+
tip_amount: (tip * 100).round,
|
|
268
|
+
discount_amount: (discount * 100).round,
|
|
269
|
+
item_count: Array(result['items']).size
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
Models::SimulatedPayment.create!(
|
|
273
|
+
simulated_order: order,
|
|
274
|
+
payment_id: SecureRandom.uuid,
|
|
275
|
+
tender_name: payment_method['name'].to_s,
|
|
276
|
+
tender_type: payment_method['type'].to_s,
|
|
277
|
+
amount: (total * 100).round,
|
|
278
|
+
tip_amount: (tip * 100).round
|
|
279
|
+
)
|
|
280
|
+
rescue StandardError => e
|
|
281
|
+
LightspeedSandboxSimulator.logger.debug("Persist failed: #{e.message}")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def generate_summary
|
|
285
|
+
return unless Database.connected?
|
|
286
|
+
|
|
287
|
+
Models::DailySummary.generate_for!(Date.today)
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
LightspeedSandboxSimulator.logger.debug("Summary generation failed: #{e.message}")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LightspeedSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class BusinessType < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'business_types'
|
|
7
|
+
|
|
8
|
+
has_many :categories, dependent: :destroy
|
|
9
|
+
has_many :items, through: :categories
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LightspeedSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class DailySummary < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'daily_summaries'
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def generate_for!(date)
|
|
10
|
+
orders = SimulatedOrder.for_date(date)
|
|
11
|
+
paid = orders.paid
|
|
12
|
+
refunded = orders.refunded
|
|
13
|
+
|
|
14
|
+
payments = SimulatedPayment.successful
|
|
15
|
+
.joins(:simulated_order)
|
|
16
|
+
.where(simulated_orders: { order_date: date })
|
|
17
|
+
|
|
18
|
+
breakdown = build_breakdown(paid, payments)
|
|
19
|
+
|
|
20
|
+
summary = find_or_initialize_by(summary_date: date)
|
|
21
|
+
summary.update!(
|
|
22
|
+
order_count: paid.count,
|
|
23
|
+
payment_count: payments.count,
|
|
24
|
+
refund_count: refunded.count,
|
|
25
|
+
total_revenue: paid.sum(:total),
|
|
26
|
+
total_tax: paid.sum(:tax_amount),
|
|
27
|
+
total_tips: paid.sum(:tip_amount),
|
|
28
|
+
total_discounts: paid.sum(:discount_amount),
|
|
29
|
+
breakdown: breakdown
|
|
30
|
+
)
|
|
31
|
+
summary
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def build_breakdown(orders, payments)
|
|
35
|
+
{
|
|
36
|
+
by_meal_period: orders.group(:meal_period).sum(:total),
|
|
37
|
+
by_dining_option: orders.group(:dining_option).sum(:total),
|
|
38
|
+
by_tender: payments.group(:tender_name).sum(:amount)
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LightspeedSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class SimulatedOrder < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'simulated_orders'
|
|
7
|
+
|
|
8
|
+
has_many :simulated_payments, dependent: :destroy
|
|
9
|
+
|
|
10
|
+
scope :for_date, ->(date) { where(order_date: date) }
|
|
11
|
+
scope :paid, -> { where(status: 'paid') }
|
|
12
|
+
scope :refunded, -> { where(status: 'refunded') }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LightspeedSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class SimulatedPayment < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'simulated_payments'
|
|
7
|
+
|
|
8
|
+
belongs_to :simulated_order
|
|
9
|
+
|
|
10
|
+
scope :successful, -> { where(status: 'successful') }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'factory_bot'
|
|
4
|
+
|
|
5
|
+
module LightspeedSandboxSimulator
|
|
6
|
+
class Seeder
|
|
7
|
+
TYPES = {
|
|
8
|
+
restaurant: { name: 'Restaurant', industry: 'food' },
|
|
9
|
+
cafe_bakery: { name: 'Cafe & Bakery', industry: 'food' },
|
|
10
|
+
bar_nightclub: { name: 'Bar & Nightclub', industry: 'food' },
|
|
11
|
+
retail_general: { name: 'Retail General', industry: 'retail' }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def seed!(business_type: :restaurant)
|
|
16
|
+
validate_business_type!(business_type)
|
|
17
|
+
|
|
18
|
+
types = business_type == :all ? TYPES.keys : [business_type]
|
|
19
|
+
result = {}
|
|
20
|
+
|
|
21
|
+
types.each do |type|
|
|
22
|
+
result[type] = seed_type(type)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_business_type!(type)
|
|
31
|
+
return if type == :all || TYPES.key?(type)
|
|
32
|
+
|
|
33
|
+
raise Error, "Unknown business type: #{type}. Valid: #{TYPES.keys.join(', ')}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def seed_type(type)
|
|
37
|
+
bt = find_or_create_business_type(type)
|
|
38
|
+
loader = Generators::DataLoader.new(business_type: type)
|
|
39
|
+
|
|
40
|
+
categories = seed_categories(bt, loader)
|
|
41
|
+
items = seed_items(categories, loader)
|
|
42
|
+
|
|
43
|
+
{ business_type: bt, categories: categories.size, items: items.size }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def find_or_create_business_type(type)
|
|
47
|
+
config = TYPES[type]
|
|
48
|
+
Models::BusinessType.find_or_create_by!(key: type.to_s) do |bt|
|
|
49
|
+
bt.name = config[:name]
|
|
50
|
+
bt.industry = config[:industry]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def seed_categories(btype, loader)
|
|
55
|
+
loader.load_categories.map do |cat|
|
|
56
|
+
btype.categories.find_or_create_by!(name: cat['name']) do |c|
|
|
57
|
+
c.sort_order = cat['sort_order'] || 0
|
|
58
|
+
c.description = cat['description']
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def seed_items(categories, loader)
|
|
64
|
+
category_map = categories.index_by(&:name)
|
|
65
|
+
|
|
66
|
+
loader.load_items.map do |item_data|
|
|
67
|
+
category = category_map[item_data['category']]
|
|
68
|
+
next unless category
|
|
69
|
+
|
|
70
|
+
category.items.find_or_create_by!(name: item_data['name']) do |i|
|
|
71
|
+
i.price = (item_data['price'].to_f * 100).round
|
|
72
|
+
i.sku = item_data['sku']
|
|
73
|
+
i.taxable = item_data.fetch('taxable', true)
|
|
74
|
+
end
|
|
75
|
+
end.compact
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'httparty'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module LightspeedSandboxSimulator
|
|
8
|
+
module Services
|
|
9
|
+
class BaseService
|
|
10
|
+
API_PREFIX = 'api/v2'
|
|
11
|
+
|
|
12
|
+
attr_reader :config
|
|
13
|
+
|
|
14
|
+
def initialize(config: nil)
|
|
15
|
+
@config = config || LightspeedSandboxSimulator.configuration
|
|
16
|
+
@config.validate!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def request(method, path, params: nil, payload: nil, 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
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
27
|
+
|
|
28
|
+
audit_request(method, url, payload, { status: response.code, body: response.body, duration: duration },
|
|
29
|
+
resource_type: resource_type, resource_id: resource_id)
|
|
30
|
+
|
|
31
|
+
handle_error_response(response) unless response.success?
|
|
32
|
+
|
|
33
|
+
parse_response(response)
|
|
34
|
+
rescue HTTParty::Error, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
35
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
36
|
+
audit_request(method, url, payload, { status: 0, body: e.message, duration: duration },
|
|
37
|
+
resource_type: resource_type, resource_id: resource_id, error: e.message)
|
|
38
|
+
raise ApiError, "HTTP error: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def execute_request(method, url, payload)
|
|
42
|
+
options = { headers: build_headers }
|
|
43
|
+
options[:body] = payload.to_json if payload
|
|
44
|
+
|
|
45
|
+
case method
|
|
46
|
+
when :get then HTTParty.get(url, options)
|
|
47
|
+
when :post then HTTParty.post(url, options)
|
|
48
|
+
when :put then HTTParty.put(url, options)
|
|
49
|
+
when :delete then HTTParty.delete(url, options)
|
|
50
|
+
else raise ApiError, "Unsupported HTTP method: #{method}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def build_headers
|
|
55
|
+
{
|
|
56
|
+
'Authorization' => "Bearer #{config.auth_token}",
|
|
57
|
+
'Accept' => 'application/json',
|
|
58
|
+
'Content-Type' => 'application/json'
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def build_url(path, params = nil)
|
|
63
|
+
url = if path.start_with?('http')
|
|
64
|
+
path
|
|
65
|
+
else
|
|
66
|
+
"#{config.base_url}/#{path}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return url unless params&.any?
|
|
70
|
+
|
|
71
|
+
query = params.map { |k, v| "#{k}=#{v}" }.join('&')
|
|
72
|
+
"#{url}?#{query}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def endpoint(resource)
|
|
76
|
+
"#{API_PREFIX}/businesses/#{config.business_id}/#{resource}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_response(response)
|
|
80
|
+
body = response.body
|
|
81
|
+
return nil if body.nil? || body.empty?
|
|
82
|
+
|
|
83
|
+
JSON.parse(body)
|
|
84
|
+
rescue JSON::ParserError
|
|
85
|
+
raise ApiError, "Invalid JSON response: #{body[0..200]}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def handle_error_response(response)
|
|
89
|
+
body = begin
|
|
90
|
+
JSON.parse(response.body)
|
|
91
|
+
rescue JSON::ParserError, TypeError
|
|
92
|
+
response.body
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
raise ApiError, "API error #{response.code}: #{body}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def fetch_all_pages(resource, params: {})
|
|
99
|
+
all_items = []
|
|
100
|
+
cursor = nil
|
|
101
|
+
|
|
102
|
+
loop do
|
|
103
|
+
request_params = params.merge({})
|
|
104
|
+
request_params[:cursor] = cursor if cursor
|
|
105
|
+
|
|
106
|
+
result = request(:get, endpoint(resource), params: request_params)
|
|
107
|
+
return all_items unless result.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
items_key = result.keys.find { |k| result[k].is_a?(Array) }
|
|
110
|
+
items = items_key ? result[items_key] : []
|
|
111
|
+
all_items.concat(items)
|
|
112
|
+
|
|
113
|
+
cursor = result['cursor']
|
|
114
|
+
break if cursor.nil? || cursor.empty? || items.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
all_items
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def with_api_fallback(fallback: nil)
|
|
121
|
+
yield
|
|
122
|
+
rescue ApiError, StandardError => e
|
|
123
|
+
LightspeedSandboxSimulator.logger.warn("API fallback: #{e.message}")
|
|
124
|
+
fallback
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def safe_dig(hash, *keys, default: nil)
|
|
128
|
+
return default unless hash.respond_to?(:dig)
|
|
129
|
+
|
|
130
|
+
hash.dig(*keys) || default
|
|
131
|
+
rescue StandardError
|
|
132
|
+
default
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def audit_request(method, url, payload, response, resource_type: nil, resource_id: nil, error: nil)
|
|
136
|
+
return unless Database.connected?
|
|
137
|
+
|
|
138
|
+
Models::ApiRequest.create!(
|
|
139
|
+
http_method: method.to_s.upcase,
|
|
140
|
+
url: url,
|
|
141
|
+
request_payload: payload&.to_json,
|
|
142
|
+
response_status: response[:status],
|
|
143
|
+
response_payload: truncate_body(response[:body]),
|
|
144
|
+
duration_ms: (response[:duration] * 1000).round,
|
|
145
|
+
resource_type: resource_type,
|
|
146
|
+
resource_id: resource_id,
|
|
147
|
+
error_message: error
|
|
148
|
+
)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
LightspeedSandboxSimulator.logger.debug("Audit log failed: #{e.message}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def truncate_body(body)
|
|
154
|
+
body.is_a?(String) ? body[0..10_000] : body.to_json[0..10_000]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LightspeedSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
module Lightspeed
|
|
6
|
+
class BusinessService < BaseService
|
|
7
|
+
def fetch_business
|
|
8
|
+
request(:get, "#{API_PREFIX}/businesses/#{config.business_id}")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def list_tax_rates
|
|
12
|
+
request(:get, endpoint('tax-rates'))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list_floors
|
|
16
|
+
request(:get, endpoint('floorplans'))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|