epos_now_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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +380 -0
  5. data/bin/simulate +309 -0
  6. data/lib/epos_now_sandbox_simulator/configuration.rb +173 -0
  7. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  8. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/items.json +26 -0
  9. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
  10. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  11. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/items.json +28 -0
  12. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
  13. data/lib/epos_now_sandbox_simulator/data/restaurant/categories.json +9 -0
  14. data/lib/epos_now_sandbox_simulator/data/restaurant/items.json +29 -0
  15. data/lib/epos_now_sandbox_simulator/data/restaurant/tenders.json +9 -0
  16. data/lib/epos_now_sandbox_simulator/data/retail_general/categories.json +9 -0
  17. data/lib/epos_now_sandbox_simulator/data/retail_general/items.json +17 -0
  18. data/lib/epos_now_sandbox_simulator/data/retail_general/tenders.json +8 -0
  19. data/lib/epos_now_sandbox_simulator/database.rb +136 -0
  20. data/lib/epos_now_sandbox_simulator/db/factories/api_requests.rb +13 -0
  21. data/lib/epos_now_sandbox_simulator/db/factories/business_types.rb +34 -0
  22. data/lib/epos_now_sandbox_simulator/db/factories/categories.rb +10 -0
  23. data/lib/epos_now_sandbox_simulator/db/factories/items.rb +12 -0
  24. data/lib/epos_now_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
  25. data/lib/epos_now_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
  26. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000001_enable_pgcrypto.rb +7 -0
  27. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000002_create_business_types.rb +16 -0
  28. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000003_create_categories.rb +16 -0
  29. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000004_create_items.rb +19 -0
  30. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000005_create_simulated_orders.rb +27 -0
  31. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000006_create_simulated_payments.rb +22 -0
  32. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000007_create_api_requests.rb +22 -0
  33. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000008_create_daily_summaries.rb +21 -0
  34. data/lib/epos_now_sandbox_simulator/generators/data_loader.rb +100 -0
  35. data/lib/epos_now_sandbox_simulator/generators/entity_generator.rb +103 -0
  36. data/lib/epos_now_sandbox_simulator/generators/order_generator.rb +336 -0
  37. data/lib/epos_now_sandbox_simulator/models/api_request.rb +16 -0
  38. data/lib/epos_now_sandbox_simulator/models/business_type.rb +16 -0
  39. data/lib/epos_now_sandbox_simulator/models/category.rb +18 -0
  40. data/lib/epos_now_sandbox_simulator/models/daily_summary.rb +43 -0
  41. data/lib/epos_now_sandbox_simulator/models/item.rb +20 -0
  42. data/lib/epos_now_sandbox_simulator/models/simulated_order.rb +21 -0
  43. data/lib/epos_now_sandbox_simulator/models/simulated_payment.rb +17 -0
  44. data/lib/epos_now_sandbox_simulator/seeder.rb +119 -0
  45. data/lib/epos_now_sandbox_simulator/services/base_service.rb +248 -0
  46. data/lib/epos_now_sandbox_simulator/services/epos_now/inventory_service.rb +178 -0
  47. data/lib/epos_now_sandbox_simulator/services/epos_now/services_manager.rb +56 -0
  48. data/lib/epos_now_sandbox_simulator/services/epos_now/tax_service.rb +45 -0
  49. data/lib/epos_now_sandbox_simulator/services/epos_now/tender_service.rb +90 -0
  50. data/lib/epos_now_sandbox_simulator/services/epos_now/transaction_service.rb +171 -0
  51. data/lib/epos_now_sandbox_simulator.rb +49 -0
  52. metadata +334 -0
@@ -0,0 +1,336 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faker"
4
+
5
+ module EposNowSandboxSimulator
6
+ module Generators
7
+ # Generates realistic daily orders in Epos Now via V4 API.
8
+ #
9
+ # V4 differences from V2:
10
+ # - ServiceType (0=EatIn, 1=Takeaway, 2=Delivery) replaces EatOut
11
+ # - TransactionItems and Tenders are embedded in the Transaction payload
12
+ # - Field names: Id instead of TransactionID, TenderTypeId instead of TypeID
13
+ # - GetByDate endpoint for date-range queries
14
+ #
15
+ # @example Generate orders for today
16
+ # generator = OrderGenerator.new
17
+ # orders = generator.generate_today(count: 25)
18
+ #
19
+ # @example Generate a full realistic day
20
+ # generator = OrderGenerator.new
21
+ # orders = generator.generate_realistic_day(multiplier: 1.5)
22
+ class OrderGenerator
23
+ # Meal period definitions
24
+ MEAL_PERIODS = {
25
+ breakfast: { hours: (7..10), weight: 15, avg_items: (2..5), avg_party: (1..3) },
26
+ lunch: { hours: (11..14), weight: 30, avg_items: (3..7), avg_party: (1..4) },
27
+ happy_hour: { hours: (15..17), weight: 10, avg_items: (2..5), avg_party: (2..5) },
28
+ dinner: { hours: (17..21), weight: 35, avg_items: (3..9), avg_party: (2..6) },
29
+ late_night: { hours: (21..23), weight: 10, avg_items: (2..5), avg_party: (1..3) }
30
+ }.freeze
31
+
32
+ # Dining option weights by meal period
33
+ DINING_BY_PERIOD = {
34
+ breakfast: { eat_in: 40, takeaway: 50, delivery: 10 },
35
+ lunch: { eat_in: 35, takeaway: 45, delivery: 20 },
36
+ happy_hour: { eat_in: 80, takeaway: 15, delivery: 5 },
37
+ dinner: { eat_in: 70, takeaway: 15, delivery: 15 },
38
+ late_night: { eat_in: 50, takeaway: 30, delivery: 20 }
39
+ }.freeze
40
+
41
+ # V4 ServiceType mapping
42
+ SERVICE_TYPE_MAP = {
43
+ eat_in: 0, # EatIn
44
+ takeaway: 1, # Takeaway
45
+ delivery: 2 # Delivery
46
+ }.freeze
47
+
48
+ # Day-of-week order counts
49
+ ORDER_PATTERNS = {
50
+ monday: (40..60), tuesday: (40..60), wednesday: (40..60),
51
+ thursday: (45..65), friday: (70..100), saturday: (80..120),
52
+ sunday: (50..80)
53
+ }.freeze
54
+
55
+ # Tip percentages by dining option
56
+ TIP_RATES = {
57
+ eat_in: { min: 15, max: 25 },
58
+ takeaway: { min: 0, max: 15 },
59
+ delivery: { min: 10, max: 20 }
60
+ }.freeze
61
+
62
+ attr_reader :refund_percentage
63
+
64
+ def initialize(refund_percentage: 5, config: nil)
65
+ @config = config || EposNowSandboxSimulator.configuration
66
+ @services = Services::EposNow::ServicesManager.new(config: @config)
67
+ @refund_percentage = refund_percentage
68
+ @data_loader = DataLoader.new(business_type: @config.business_type)
69
+ end
70
+
71
+ # Generate orders for today
72
+ # @param count [Integer, nil] Number of orders (nil = random based on day)
73
+ # @return [Array<Hash>] Generated orders
74
+ def generate_today(count: nil)
75
+ data = fetch_required_data
76
+ count ||= daily_order_count
77
+
78
+ logger.info "Generating #{count} orders for today..."
79
+
80
+ orders = distribute_across_periods(count).flat_map do |period, period_count|
81
+ period_count.times.map do
82
+ generate_single_order(data, period: period)
83
+ end
84
+ end
85
+
86
+ logger.info "Generated #{orders.compact.size} orders"
87
+
88
+ # Process refunds
89
+ process_refunds(orders.compact) if refund_percentage.positive?
90
+
91
+ # Generate daily summary
92
+ generate_summary if Database.connected?
93
+
94
+ orders.compact
95
+ end
96
+
97
+ # Generate a realistic full day
98
+ # @param multiplier [Float] Volume multiplier (0.5 = slow, 2.0 = busy)
99
+ # @return [Array<Hash>] All generated orders
100
+ def generate_realistic_day(multiplier: 1.0)
101
+ count = (daily_order_count * multiplier).round
102
+ generate_today(count: count)
103
+ end
104
+
105
+ # Generate orders for a specific meal period
106
+ # @param period [Symbol] Meal period
107
+ # @param count [Integer] Number of orders
108
+ # @return [Array<Hash>] Generated orders
109
+ def generate_rush(period:, count: 15)
110
+ data = fetch_required_data
111
+ logger.info "Generating #{period} rush: #{count} orders..."
112
+
113
+ orders = count.times.map do
114
+ generate_single_order(data, period: period)
115
+ end
116
+
117
+ orders.compact
118
+ end
119
+
120
+ private
121
+
122
+ def logger
123
+ EposNowSandboxSimulator.logger
124
+ end
125
+
126
+ # Fetch all required data (products, tender types)
127
+ def fetch_required_data
128
+ products = @services.inventory.fetch_products
129
+ tender_types = @services.tender.fetch_tender_types
130
+ tenders_config = @data_loader.load_tenders
131
+
132
+ raise Error, "No products found. Run setup first." if products.empty?
133
+ raise Error, "No tender types found. Run setup first." if tender_types.empty?
134
+
135
+ {
136
+ products: products,
137
+ tender_types: tender_types,
138
+ tenders_config: tenders_config
139
+ }
140
+ end
141
+
142
+ # Generate a single complete order using V4 API
143
+ def generate_single_order(data, period:)
144
+ dining_option = weighted_select(DINING_BY_PERIOD[period])
145
+ service_type = SERVICE_TYPE_MAP[dining_option]
146
+ item_count = rand(MEAL_PERIODS[period][:avg_items])
147
+
148
+ # Pick random products
149
+ selected_products = data[:products].sample(item_count)
150
+ return nil if selected_products.empty?
151
+
152
+ # Calculate totals
153
+ subtotal = selected_products.sum { |p| p["SalePrice"].to_f }
154
+ discount = calculate_discount(subtotal)
155
+ tax = @services.tax.calculate_tax(subtotal - discount)
156
+ tip = calculate_tip(subtotal, dining_option)
157
+ total = subtotal - discount + tax + tip
158
+
159
+ # Select tender (payment method)
160
+ tender_type = select_tender(data[:tender_types], data[:tenders_config])
161
+
162
+ # Build V4 items payload (embedded in transaction)
163
+ items = selected_products.map do |product|
164
+ {
165
+ product_id: product["Id"],
166
+ quantity: 1,
167
+ unit_price: product["SalePrice"].to_f
168
+ }
169
+ end
170
+
171
+ # Build V4 tenders payload (embedded in transaction)
172
+ tenders = [{
173
+ tender_type_id: tender_type["Id"],
174
+ amount: total.round(2),
175
+ change_given: 0.0
176
+ }]
177
+
178
+ # Create the complete transaction via V4 API (single call)
179
+ result = @services.transaction.create_transaction(
180
+ items: items,
181
+ tenders: tenders,
182
+ service_type: service_type,
183
+ gratuity: tip.round(2),
184
+ discount_value: discount.round(2)
185
+ )
186
+
187
+ # Persist locally if DB is connected
188
+ persist_order(result, period, dining_option, subtotal, tax, tip, discount, total, tender_type) if Database.connected?
189
+
190
+ total_str = format("%.2f", total)
191
+ logger.info "Order #{result["Id"]}: #{selected_products.size} items, $#{total_str} (#{dining_option}, #{tender_type["Name"]})"
192
+ result
193
+ rescue StandardError => e
194
+ logger.error "Failed to generate order: #{e.message}"
195
+ nil
196
+ end
197
+
198
+ # Persist order to local DB for tracking
199
+ def persist_order(result, period, dining_option, subtotal, tax, tip, discount, total, tender_type)
200
+ order = Models::SimulatedOrder.create!(
201
+ epos_now_transaction_id: result["Id"],
202
+ status: "paid",
203
+ business_date: @config.merchant_date_today,
204
+ dining_option: dining_option.to_s,
205
+ meal_period: period.to_s,
206
+ subtotal: (subtotal * 100).round,
207
+ tax_amount: (tax * 100).round,
208
+ tip_amount: (tip * 100).round,
209
+ discount_amount: (discount * 100).round,
210
+ total: (total * 100).round,
211
+ metadata: { items_count: (result["TransactionItems"] || []).size }
212
+ )
213
+
214
+ Models::SimulatedPayment.create!(
215
+ simulated_order: order,
216
+ epos_now_tender_id: (result["Tenders"]&.first || {})["Id"],
217
+ tender_name: tender_type["Name"],
218
+ amount: (total * 100).round,
219
+ tip_amount: (tip * 100).round,
220
+ status: "success",
221
+ payment_type: tender_type["Name"]&.downcase&.gsub(" ", "_")
222
+ )
223
+ rescue StandardError => e
224
+ logger.debug "Failed to persist order: #{e.message}"
225
+ end
226
+
227
+ # Distribute orders across meal periods based on weights
228
+ def distribute_across_periods(total_count)
229
+ total_weight = MEAL_PERIODS.values.sum { |p| p[:weight] }
230
+ distribution = {}
231
+
232
+ MEAL_PERIODS.each do |period, config|
233
+ count = (total_count.to_f * config[:weight] / total_weight).round
234
+ distribution[period] = count if count.positive?
235
+ end
236
+
237
+ # Ensure we match the total
238
+ diff = total_count - distribution.values.sum
239
+ distribution[:dinner] = (distribution[:dinner] || 0) + diff if diff != 0
240
+
241
+ distribution
242
+ end
243
+
244
+ # Calculate random daily order count based on day of week
245
+ def daily_order_count
246
+ day = @config.merchant_date_today.strftime("%A").downcase.to_sym
247
+ range = ORDER_PATTERNS[day] || (40..60)
248
+ rand(range)
249
+ end
250
+
251
+ # Weighted random selection
252
+ def weighted_select(weights)
253
+ total = weights.values.sum
254
+ roll = rand(total)
255
+ cumulative = 0
256
+
257
+ weights.each do |key, weight|
258
+ cumulative += weight
259
+ return key if roll < cumulative
260
+ end
261
+
262
+ weights.keys.last
263
+ end
264
+
265
+ # Select a tender type based on weights from JSON config
266
+ def select_tender(tender_types, tenders_config)
267
+ weights = {}
268
+ tenders_config.each do |tc|
269
+ matching = tender_types.find { |tt| tt["Name"]&.downcase == tc["name"]&.downcase }
270
+ weights[matching] = tc["weight"] if matching
271
+ end
272
+
273
+ return tender_types.first if weights.empty?
274
+
275
+ weighted_select(weights)
276
+ end
277
+
278
+ # Calculate discount (8% chance of 10-20% discount)
279
+ def calculate_discount(subtotal)
280
+ return 0.0 if rand(100) >= 8
281
+
282
+ rate = rand(10..20) / 100.0
283
+ (subtotal * rate).round(2)
284
+ end
285
+
286
+ # Calculate tip based on dining option
287
+ def calculate_tip(subtotal, dining_option)
288
+ rates = TIP_RATES[dining_option] || TIP_RATES[:eat_in]
289
+
290
+ # Tip probability varies by dining option
291
+ tip_chance = case dining_option
292
+ when :eat_in then 70
293
+ when :takeaway then 40
294
+ when :delivery then 60
295
+ else 50
296
+ end
297
+
298
+ return 0.0 if rand(100) >= tip_chance
299
+
300
+ rate = rand(rates[:min]..rates[:max]) / 100.0
301
+ (subtotal * rate).round(2)
302
+ end
303
+
304
+ # Process refunds on a percentage of orders
305
+ def process_refunds(orders)
306
+ return if orders.empty?
307
+
308
+ refund_count = (orders.size * refund_percentage / 100.0).ceil
309
+ return if refund_count.zero?
310
+
311
+ logger.info "Processing #{refund_count} refunds..."
312
+
313
+ orders.sample(refund_count).each do |order|
314
+ transaction_id = order["Id"]
315
+ next unless transaction_id
316
+
317
+ # Mark as refunded in local DB
318
+ if Database.connected?
319
+ simulated = Models::SimulatedOrder.find_by(epos_now_transaction_id: transaction_id)
320
+ simulated&.update!(status: "refunded")
321
+ end
322
+
323
+ logger.info "Refunded transaction #{transaction_id}"
324
+ end
325
+ end
326
+
327
+ # Generate daily summary
328
+ def generate_summary
329
+ Models::DailySummary.generate_for!(@config.merchant_date_today)
330
+ logger.info "Daily summary generated"
331
+ rescue StandardError => e
332
+ logger.debug "Failed to generate summary: #{e.message}"
333
+ end
334
+ end
335
+ end
336
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Models
5
+ class ApiRequest < ActiveRecord::Base
6
+ self.table_name = "api_requests"
7
+
8
+ validates :http_method, presence: true
9
+ validates :url, presence: true
10
+
11
+ scope :errors, -> { where.not(error_message: nil) }
12
+ scope :by_resource, ->(type) { where(resource_type: type) }
13
+ scope :recent, -> { order(created_at: :desc) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Models
5
+ class BusinessType < ActiveRecord::Base
6
+ self.table_name = "business_types"
7
+
8
+ has_many :categories, class_name: "EposNowSandboxSimulator::Models::Category", dependent: :destroy
9
+ has_many :items, class_name: "EposNowSandboxSimulator::Models::Item", dependent: :destroy
10
+
11
+ validates :key, presence: true, uniqueness: true
12
+ validates :name, presence: true
13
+ validates :industry, presence: true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Models
5
+ class Category < ActiveRecord::Base
6
+ self.table_name = "categories"
7
+
8
+ belongs_to :business_type, class_name: "EposNowSandboxSimulator::Models::BusinessType"
9
+ has_many :items, class_name: "EposNowSandboxSimulator::Models::Item", dependent: :nullify
10
+
11
+ validates :name, presence: true
12
+ validates :sort_order, presence: true
13
+ validates :name, uniqueness: { scope: :business_type_id }
14
+
15
+ scope :for_business_type, ->(bt) { where(business_type: bt) }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Models
5
+ class DailySummary < ActiveRecord::Base
6
+ self.table_name = "daily_summaries"
7
+
8
+ validates :summary_date, presence: true, uniqueness: true
9
+
10
+ # Generate or update a daily summary for a given date
11
+ # @param date [Date] The date to summarize
12
+ # @return [DailySummary]
13
+ def self.generate_for!(date)
14
+ orders = SimulatedOrder.for_date(date).paid
15
+ payments = SimulatedPayment.successful.joins(:simulated_order)
16
+ .where(simulated_orders: { business_date: date, status: "paid" })
17
+
18
+ attrs = {
19
+ order_count: orders.count,
20
+ payment_count: payments.count,
21
+ refund_count: SimulatedOrder.for_date(date).refunded.count,
22
+ total_revenue: orders.sum(:total),
23
+ total_tax: orders.sum(:tax_amount),
24
+ total_tips: orders.sum(:tip_amount),
25
+ total_discounts: orders.sum(:discount_amount),
26
+ breakdown: build_breakdown(orders, payments)
27
+ }
28
+
29
+ summary = find_or_initialize_by(summary_date: date)
30
+ summary.update!(attrs)
31
+ summary
32
+ end
33
+
34
+ def self.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
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Models
5
+ class Item < ActiveRecord::Base
6
+ self.table_name = "items"
7
+
8
+ belongs_to :business_type, class_name: "EposNowSandboxSimulator::Models::BusinessType"
9
+ belongs_to :category, class_name: "EposNowSandboxSimulator::Models::Category", optional: true
10
+
11
+ validates :name, presence: true
12
+ validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }
13
+ validates :sku, uniqueness: { allow_nil: true }
14
+
15
+ scope :for_business_type, ->(bt) { where(business_type: bt) }
16
+ scope :for_category, ->(cat) { where(category: cat) }
17
+ scope :by_price, -> { order(price: :asc) }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Models
5
+ class SimulatedOrder < ActiveRecord::Base
6
+ self.table_name = "simulated_orders"
7
+
8
+ has_many :simulated_payments, class_name: "EposNowSandboxSimulator::Models::SimulatedPayment", dependent: :destroy
9
+
10
+ validates :epos_now_transaction_id, presence: true
11
+ validates :status, presence: true, inclusion: { in: %w[open paid refunded] }
12
+ validates :dining_option, inclusion: { in: %w[walk_in take_away delivery], allow_nil: true }
13
+ validates :meal_period, inclusion: { in: %w[breakfast lunch happy_hour dinner late_night], allow_nil: true }
14
+
15
+ scope :paid, -> { where(status: "paid") }
16
+ scope :refunded, -> { where(status: "refunded") }
17
+ scope :for_date, ->(date) { where(business_date: date) }
18
+ scope :for_meal_period, ->(period) { where(meal_period: period) }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Models
5
+ class SimulatedPayment < ActiveRecord::Base
6
+ self.table_name = "simulated_payments"
7
+
8
+ belongs_to :simulated_order, class_name: "EposNowSandboxSimulator::Models::SimulatedOrder"
9
+
10
+ validates :tender_name, presence: true
11
+ validates :amount, presence: true
12
+ validates :status, presence: true, inclusion: { in: %w[pending success failed] }
13
+
14
+ scope :successful, -> { where(status: "success") }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ # Seeds the database with business types, categories, and items.
5
+ # Idempotent — safe to call multiple times.
6
+ module Seeder
7
+ SEED_MAP = {
8
+ restaurant: {
9
+ industry: "Food",
10
+ categories: {
11
+ appetizers: %i[buffalo_wings mozzarella_sticks loaded_nachos bruschetta calamari],
12
+ entrees: %i[grilled_salmon ny_strip_steak chicken_parmesan fish_and_chips pasta_alfredo bbq_ribs caesar_salad],
13
+ sides: %i[french_fries coleslaw mac_and_cheese garden_salad onion_rings],
14
+ drinks: %i[coca_cola iced_tea draft_beer house_wine lemonade],
15
+ desserts: %i[chocolate_cake cheesecake ice_cream_sundae]
16
+ }
17
+ },
18
+ cafe_bakery: {
19
+ industry: "Food",
20
+ categories: {
21
+ hot_drinks: %i[espresso cappuccino latte hot_chocolate english_breakfast_tea],
22
+ cold_drinks: %i[iced_latte iced_americano smoothie fresh_orange_juice],
23
+ pastries: %i[croissant pain_au_chocolat blueberry_muffin cinnamon_roll scone],
24
+ sandwiches: %i[club_sandwich blt avocado_toast panini chicken_wrap],
25
+ cakes: %i[carrot_cake victoria_sponge brownie lemon_drizzle red_velvet_slice]
26
+ }
27
+ },
28
+ bar_nightclub: {
29
+ industry: "Food",
30
+ categories: {
31
+ draft_beer: %i[ipa_pint lager_pint stout_pint wheat_beer pale_ale],
32
+ bottled_beer: %i[corona heineken budweiser seltzer],
33
+ cocktails: %i[margarita old_fashioned mojito espresso_martini long_island],
34
+ wine: %i[house_red_wine house_white_wine prosecco],
35
+ bar_snacks: %i[chicken_wings loaded_fries nachos slider_trio mixed_nuts]
36
+ }
37
+ },
38
+ retail_general: {
39
+ industry: "Retail",
40
+ categories: {
41
+ electronics: %i[phone_charger bluetooth_speaker usb_cable],
42
+ clothing: %i[t_shirt baseball_cap socks_pack],
43
+ home_and_garden: %i[candle plant_pot picture_frame],
44
+ health_beauty: %i[hand_cream lip_balm],
45
+ groceries: %i[snack_bar water_bottle]
46
+ }
47
+ }
48
+ }.freeze
49
+
50
+ class << self
51
+ # Seed database with business type data
52
+ #
53
+ # @param business_type [Symbol, String, nil] Specific type or nil for all
54
+ # @return [Hash] Summary counts
55
+ def seed!(business_type: nil)
56
+ types = business_type ? [business_type.to_sym] : SEED_MAP.keys
57
+
58
+ counts = { business_types: 0, categories: 0, items: 0 }
59
+
60
+ types.each do |bt_key|
61
+ bt_config = SEED_MAP[bt_key]
62
+ next unless bt_config
63
+
64
+ bt = find_or_create_business_type(bt_key, bt_config)
65
+ counts[:business_types] += 1
66
+
67
+ seed_categories(bt, bt_key, bt_config, counts)
68
+ end
69
+
70
+ EposNowSandboxSimulator.logger.info "Seeded: #{counts}"
71
+ counts
72
+ end
73
+
74
+ private
75
+
76
+ def find_or_create_business_type(bt_key, bt_config)
77
+ Models::BusinessType.find_or_create_by!(key: bt_key.to_s) do |b|
78
+ b.name = humanize_key(bt_key)
79
+ b.industry = bt_config[:industry]
80
+ end
81
+ end
82
+
83
+ def seed_categories(bt, bt_key, bt_config, counts)
84
+ loader = Generators::DataLoader.new(business_type: bt_key)
85
+ items_data = loader.load_items
86
+
87
+ bt_config[:categories].each_with_index do |(cat_key, item_keys), idx|
88
+ cat = Models::Category.find_or_create_by!(
89
+ business_type: bt,
90
+ name: humanize_key(cat_key)
91
+ ) do |c|
92
+ c.sort_order = idx + 1
93
+ end
94
+ counts[:categories] += 1
95
+
96
+ seed_items(bt, cat, item_keys, items_data, counts)
97
+ end
98
+ end
99
+
100
+ def seed_items(bt, cat, item_keys, items_data, counts)
101
+ item_keys.each do |item_key|
102
+ item_name = humanize_key(item_key)
103
+ json_item = items_data.find { |i| i["name"] == item_name }
104
+
105
+ Models::Item.find_or_create_by!(business_type: bt, name: item_name) do |i|
106
+ i.category = cat
107
+ i.price = json_item ? (json_item["price"] * 100).round : 999
108
+ i.sku = json_item&.dig("sku")
109
+ end
110
+ counts[:items] += 1
111
+ end
112
+ end
113
+
114
+ def humanize_key(key)
115
+ key.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
116
+ end
117
+ end
118
+ end
119
+ end