skytab_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/Gemfile +10 -0
  3. data/bin/simulate +433 -0
  4. data/lib/skytab_sandbox_simulator/configuration.rb +205 -0
  5. data/lib/skytab_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  6. data/lib/skytab_sandbox_simulator/data/bar_nightclub/items.json +28 -0
  7. data/lib/skytab_sandbox_simulator/data/bar_nightclub/tenders.json +19 -0
  8. data/lib/skytab_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  9. data/lib/skytab_sandbox_simulator/data/cafe_bakery/items.json +30 -0
  10. data/lib/skytab_sandbox_simulator/data/cafe_bakery/tenders.json +17 -0
  11. data/lib/skytab_sandbox_simulator/data/fine_dining/categories.json +9 -0
  12. data/lib/skytab_sandbox_simulator/data/fine_dining/items.json +30 -0
  13. data/lib/skytab_sandbox_simulator/data/fine_dining/tenders.json +18 -0
  14. data/lib/skytab_sandbox_simulator/data/pizzeria/categories.json +9 -0
  15. data/lib/skytab_sandbox_simulator/data/pizzeria/items.json +28 -0
  16. data/lib/skytab_sandbox_simulator/data/pizzeria/tenders.json +18 -0
  17. data/lib/skytab_sandbox_simulator/data/restaurant/categories.json +44 -0
  18. data/lib/skytab_sandbox_simulator/data/restaurant/items.json +59 -0
  19. data/lib/skytab_sandbox_simulator/data/restaurant/tenders.json +22 -0
  20. data/lib/skytab_sandbox_simulator/database.rb +192 -0
  21. data/lib/skytab_sandbox_simulator/db/factories/business_types.rb +102 -0
  22. data/lib/skytab_sandbox_simulator/db/factories/categories.rb +243 -0
  23. data/lib/skytab_sandbox_simulator/db/factories/items.rb +976 -0
  24. data/lib/skytab_sandbox_simulator/db/factories/simulated_orders.rb +120 -0
  25. data/lib/skytab_sandbox_simulator/db/factories/simulated_payments.rb +75 -0
  26. data/lib/skytab_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
  27. data/lib/skytab_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
  28. data/lib/skytab_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
  29. data/lib/skytab_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
  30. data/lib/skytab_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +35 -0
  31. data/lib/skytab_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
  32. data/lib/skytab_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
  33. data/lib/skytab_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
  34. data/lib/skytab_sandbox_simulator/generators/data_loader.rb +125 -0
  35. data/lib/skytab_sandbox_simulator/generators/entity_generator.rb +107 -0
  36. data/lib/skytab_sandbox_simulator/generators/order_generator.rb +390 -0
  37. data/lib/skytab_sandbox_simulator/models/api_request.rb +43 -0
  38. data/lib/skytab_sandbox_simulator/models/business_type.rb +25 -0
  39. data/lib/skytab_sandbox_simulator/models/category.rb +17 -0
  40. data/lib/skytab_sandbox_simulator/models/daily_summary.rb +67 -0
  41. data/lib/skytab_sandbox_simulator/models/item.rb +32 -0
  42. data/lib/skytab_sandbox_simulator/models/record.rb +14 -0
  43. data/lib/skytab_sandbox_simulator/models/simulated_order.rb +40 -0
  44. data/lib/skytab_sandbox_simulator/models/simulated_payment.rb +28 -0
  45. data/lib/skytab_sandbox_simulator/seeder.rb +167 -0
  46. data/lib/skytab_sandbox_simulator/services/base_service.rb +227 -0
  47. data/lib/skytab_sandbox_simulator/services/skytab/catalog_service.rb +130 -0
  48. data/lib/skytab_sandbox_simulator/services/skytab/location_service.rb +54 -0
  49. data/lib/skytab_sandbox_simulator/services/skytab/order_service.rb +139 -0
  50. data/lib/skytab_sandbox_simulator/services/skytab/payment_service.rb +94 -0
  51. data/lib/skytab_sandbox_simulator/services/skytab/service_manager.rb +62 -0
  52. data/lib/skytab_sandbox_simulator/version.rb +5 -0
  53. data/lib/skytab_sandbox_simulator.rb +45 -0
  54. metadata +305 -0
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Generators
5
+ # Generates realistic SkyTab orders (tickets) and payments
6
+ class OrderGenerator
7
+ # Meal periods with realistic distributions
8
+ MEAL_PERIODS = {
9
+ breakfast: { hours: 7..10, weight: 15, avg_items: 2..4, avg_party: 1..3 },
10
+ lunch: { hours: 11..14, weight: 30, avg_items: 3..6, avg_party: 1..4 },
11
+ happy_hour: { hours: 15..17, weight: 10, avg_items: 2..5, avg_party: 2..5 },
12
+ dinner: { hours: 17..21, weight: 35, avg_items: 3..8, avg_party: 2..6 },
13
+ late_night: { hours: 21..23, weight: 10, avg_items: 2..5, avg_party: 1..3 }
14
+ }.freeze
15
+
16
+ # Revenue class distributions by meal period
17
+ REVENUE_CLASS_BY_PERIOD = {
18
+ breakfast: { "DINE_IN" => 40, "TAKEOUT" => 50, "DELIVERY" => 10 },
19
+ lunch: { "DINE_IN" => 35, "TAKEOUT" => 45, "DELIVERY" => 20 },
20
+ happy_hour: { "DINE_IN" => 60, "BAR" => 30, "TAKEOUT" => 10 },
21
+ dinner: { "DINE_IN" => 65, "BAR" => 10, "TAKEOUT" => 10, "DELIVERY" => 15 },
22
+ late_night: { "BAR" => 40, "DINE_IN" => 30, "TAKEOUT" => 20, "DELIVERY" => 10 }
23
+ }.freeze
24
+
25
+ # Tip percentages by revenue class
26
+ TIP_RATES = {
27
+ "DINE_IN" => { min: 15, max: 25 },
28
+ "TAKEOUT" => { min: 0, max: 15 },
29
+ "DELIVERY" => { min: 10, max: 20 },
30
+ "BAR" => { min: 15, max: 25 },
31
+ "CATERING" => { min: 18, max: 22 }
32
+ }.freeze
33
+
34
+ # Order patterns by day of week
35
+ ORDER_PATTERNS = {
36
+ weekday: { min: 40, max: 60 },
37
+ friday: { min: 70, max: 100 },
38
+ saturday: { min: 80, max: 120 },
39
+ sunday: { min: 50, max: 80 }
40
+ }.freeze
41
+
42
+ # Tender distribution weights
43
+ TENDER_WEIGHTS = {
44
+ "CASH" => 15,
45
+ "VISA" => 30,
46
+ "MASTERCARD" => 20,
47
+ "AMEX" => 10,
48
+ "DISCOVER" => 5,
49
+ "GIFT_CARD" => 10,
50
+ "HOUSE_ACCOUNT" => 10
51
+ }.freeze
52
+
53
+ # Tax rates (divide by 10,000 for percentage)
54
+ SALES_TAX_RATE = 82_500 # 8.25%
55
+ ALCOHOL_TAX_RATE = 100_000 # 10.0%
56
+
57
+ attr_reader :services, :logger, :stats, :refund_percentage
58
+
59
+ def initialize(services: nil, refund_percentage: 5)
60
+ @services = services || Services::Skytab::ServiceManager.new
61
+ @logger = SkytabSandboxSimulator.logger
62
+ @refund_percentage = refund_percentage
63
+ @stats = {
64
+ orders: 0,
65
+ revenue: 0,
66
+ tips: 0,
67
+ tax: 0,
68
+ by_period: Hash.new(0),
69
+ by_revenue_class: Hash.new(0),
70
+ by_tender: Hash.new(0)
71
+ }
72
+ end
73
+
74
+ # Generate orders for today
75
+ #
76
+ # @param count [Integer, nil] Number of orders (nil for automatic based on day)
77
+ # @return [Array<Hash>] Generated orders
78
+ def generate_today(count: nil)
79
+ count ||= determine_order_count
80
+ logger.info "Generating #{count} orders for today..."
81
+
82
+ data = fetch_required_data
83
+ return [] unless data
84
+
85
+ orders = []
86
+ count.times do |i|
87
+ period = select_meal_period
88
+ order = create_realistic_order(
89
+ period: period,
90
+ data: data,
91
+ order_num: i + 1,
92
+ total_in_period: count
93
+ )
94
+
95
+ if order
96
+ orders << order
97
+ update_stats(order, period)
98
+ end
99
+ end
100
+
101
+ print_summary
102
+ orders
103
+ end
104
+
105
+ # Generate a realistic full day of operations
106
+ #
107
+ # @param multiplier [Float] Order multiplier (0.5 = slow, 2.0 = busy)
108
+ # @return [Array<Hash>] Generated orders
109
+ def generate_realistic_day(multiplier: 1.0)
110
+ logger.info "Generating realistic day (multiplier: #{multiplier})..."
111
+
112
+ data = fetch_required_data
113
+ return [] unless data
114
+
115
+ orders = []
116
+ MEAL_PERIODS.each do |period, config|
117
+ period_count = (config[:weight] * multiplier * 0.6).round
118
+ period_count = [period_count, 1].max
119
+
120
+ logger.info "#{period}: #{period_count} orders"
121
+
122
+ period_count.times do |i|
123
+ order = create_realistic_order(
124
+ period: period,
125
+ data: data,
126
+ order_num: i + 1,
127
+ total_in_period: period_count
128
+ )
129
+
130
+ if order
131
+ orders << order
132
+ update_stats(order, period)
133
+ end
134
+ end
135
+ end
136
+
137
+ print_summary
138
+ orders
139
+ end
140
+
141
+ private
142
+
143
+ def fetch_required_data
144
+ data_loader = DataLoader.new(business_type: SkytabSandboxSimulator.configuration.business_type)
145
+
146
+ items = data_loader.items
147
+ categories = data_loader.categories
148
+ tenders = data_loader.tenders
149
+
150
+ if items.empty?
151
+ logger.error "No items found. Run setup first."
152
+ return nil
153
+ end
154
+
155
+ { items: items, categories: categories, tenders: tenders }
156
+ end
157
+
158
+ def create_realistic_order(period:, data:, order_num:, total_in_period:)
159
+ revenue_class = select_revenue_class(period)
160
+ items_count = rand(MEAL_PERIODS[period][:avg_items])
161
+ selected_items = data[:items].sample([items_count, 1].max)
162
+
163
+ # Build SkyTab order format
164
+ pos_ref = "CHK-#{Time.now.strftime('%H%M')}-#{order_num.to_s.rjust(3, '0')}"
165
+ employee_ref = "EMP#{format('%03d', rand(1..8))}"
166
+
167
+ # Calculate totals
168
+ subtotal = selected_items.sum { |item| item["price"] }
169
+ tax = (subtotal * SALES_TAX_RATE / 1_000_000.0).round
170
+ total = subtotal + tax
171
+ tip = calculate_tip(total, revenue_class)
172
+
173
+ # Select tender
174
+ tender = select_tender
175
+
176
+ # Create ticket via API
177
+ ticket = services.order.create_ticket(
178
+ pos_ref: pos_ref,
179
+ employee_pos_ref: employee_ref,
180
+ revenue_class: revenue_class
181
+ )
182
+
183
+ return nil unless ticket
184
+
185
+ ticket_id = ticket["id"] || ticket["posRef"] || pos_ref
186
+
187
+ # Add items to ticket
188
+ selected_items.each do |item|
189
+ services.order.add_item_to_ticket(
190
+ ticket_id,
191
+ pos_ref: item["sku"] || item["name"].upcase.tr(" ", "_"),
192
+ name: item["name"],
193
+ quantity: 1,
194
+ price: item["price"]
195
+ )
196
+ rescue StandardError => e
197
+ logger.warn "Failed to add item to ticket: #{e.message}"
198
+ end
199
+
200
+ # Add payment
201
+ services.payment.add_payment(
202
+ ticket_id,
203
+ tender_pos_ref: tender,
204
+ amount: total,
205
+ tip: tip
206
+ )
207
+ rescue StandardError => e
208
+ logger.warn "Failed to add payment: #{e.message}"
209
+ end
210
+
211
+ # Close ticket
212
+ services.order.close_ticket(
213
+ ticket_id,
214
+ subtotal: subtotal,
215
+ tax: tax,
216
+ total: total,
217
+ tip: tip
218
+ )
219
+
220
+ # Persist to local DB if connected
221
+ persist_order(
222
+ ticket_id: ticket_id,
223
+ pos_ref: pos_ref,
224
+ revenue_class: revenue_class,
225
+ period: period,
226
+ subtotal: subtotal,
227
+ tax: tax,
228
+ total: total,
229
+ tip: tip,
230
+ tender: tender,
231
+ items: selected_items
232
+ )
233
+
234
+ {
235
+ "posRef" => pos_ref,
236
+ "status" => "closed",
237
+ "revenueClassPosRef" => revenue_class,
238
+ "employeePosRef" => employee_ref,
239
+ "items" => selected_items.map do |item|
240
+ { "posRef" => item["sku"] || item["name"], "name" => item["name"],
241
+ "quantity" => 1, "price" => item["price"], "total" => item["price"] }
242
+ end,
243
+ "payments" => [
244
+ { "tenderPosRef" => tender, "amount" => total, "tip" => tip, "status" => "completed" }
245
+ ],
246
+ "subtotal" => subtotal,
247
+ "tax" => tax,
248
+ "total" => total,
249
+ "tip" => tip
250
+ }
251
+ rescue StandardError => e
252
+ logger.error "Failed to create order: #{e.message}"
253
+ nil
254
+ end
255
+
256
+ def persist_order(ticket_id:, pos_ref:, revenue_class:, period:, subtotal:, tax:, total:, tip:, tender:, items:)
257
+ return unless Database.connected?
258
+
259
+ location_id = SkytabSandboxSimulator.configuration.location_id
260
+
261
+ order = Models::SimulatedOrder.create!(
262
+ skytab_ticket_id: ticket_id,
263
+ skytab_location_id: location_id,
264
+ status: "closed",
265
+ business_date: Date.today,
266
+ subtotal: subtotal,
267
+ tax_amount: tax,
268
+ tip_amount: tip,
269
+ discount_amount: 0,
270
+ total: total,
271
+ revenue_class: revenue_class,
272
+ meal_period: period.to_s,
273
+ metadata: { pos_ref: pos_ref, items: items.map { |i| i["name"] } }
274
+ )
275
+
276
+ Models::SimulatedPayment.create!(
277
+ simulated_order: order,
278
+ skytab_payment_id: "PAY-#{SecureRandom.hex(6).upcase}",
279
+ tender_name: tender,
280
+ amount: total,
281
+ tip_amount: tip,
282
+ tax_amount: tax,
283
+ status: "completed",
284
+ payment_type: tender == "CASH" ? "cash" : "card"
285
+ )
286
+ rescue StandardError => e
287
+ logger.debug "Failed to persist order: #{e.message}"
288
+ end
289
+
290
+ def determine_order_count
291
+ day = Date.today.wday
292
+ pattern = case day
293
+ when 5 then ORDER_PATTERNS[:friday]
294
+ when 6 then ORDER_PATTERNS[:saturday]
295
+ when 0 then ORDER_PATTERNS[:sunday]
296
+ else ORDER_PATTERNS[:weekday]
297
+ end
298
+ rand(pattern[:min]..pattern[:max])
299
+ end
300
+
301
+ def select_meal_period
302
+ total_weight = MEAL_PERIODS.values.sum { |v| v[:weight] }
303
+ roll = rand(total_weight)
304
+ cumulative = 0
305
+
306
+ MEAL_PERIODS.each do |period, config|
307
+ cumulative += config[:weight]
308
+ return period if roll < cumulative
309
+ end
310
+
311
+ :dinner
312
+ end
313
+
314
+ def select_revenue_class(period)
315
+ dist = REVENUE_CLASS_BY_PERIOD[period]
316
+ total_weight = dist.values.sum
317
+ roll = rand(total_weight)
318
+ cumulative = 0
319
+
320
+ dist.each do |revenue_class, weight|
321
+ cumulative += weight
322
+ return revenue_class if roll < cumulative
323
+ end
324
+
325
+ "DINE_IN"
326
+ end
327
+
328
+ def select_tender
329
+ total_weight = TENDER_WEIGHTS.values.sum
330
+ roll = rand(total_weight)
331
+ cumulative = 0
332
+
333
+ TENDER_WEIGHTS.each do |tender, weight|
334
+ cumulative += weight
335
+ return tender if roll < cumulative
336
+ end
337
+
338
+ "VISA"
339
+ end
340
+
341
+ def calculate_tip(total, revenue_class)
342
+ rates = TIP_RATES[revenue_class] || TIP_RATES["DINE_IN"]
343
+ tip_pct = rand(rates[:min]..rates[:max])
344
+ (total * tip_pct / 100.0).round
345
+ end
346
+
347
+ def update_stats(order, period)
348
+ return unless order
349
+
350
+ @stats[:orders] += 1
351
+ @stats[:revenue] += order["total"] || 0
352
+ @stats[:tips] += order["tip"] || 0
353
+ @stats[:tax] += order["tax"] || 0
354
+ @stats[:by_period][period] += 1
355
+ @stats[:by_revenue_class][order["revenueClassPosRef"]] += 1
356
+
357
+ tender = order.dig("payments", 0, "tenderPosRef")
358
+ @stats[:by_tender][tender] += 1 if tender
359
+ end
360
+
361
+ def print_summary
362
+ logger.info "=" * 50
363
+ logger.info "Order Generation Summary"
364
+ logger.info "=" * 50
365
+ logger.info " Total orders: #{@stats[:orders]}"
366
+ logger.info " Revenue: $#{(@stats[:revenue] / 100.0).round(2)}"
367
+ logger.info " Tips: $#{(@stats[:tips] / 100.0).round(2)}"
368
+ logger.info " Tax: $#{(@stats[:tax] / 100.0).round(2)}"
369
+
370
+ if @stats[:by_period].any?
371
+ logger.info ""
372
+ logger.info " By Period:"
373
+ @stats[:by_period].each { |p, c| logger.info " #{p}: #{c}" }
374
+ end
375
+
376
+ if @stats[:by_revenue_class].any?
377
+ logger.info ""
378
+ logger.info " By Revenue Class:"
379
+ @stats[:by_revenue_class].each { |rc, c| logger.info " #{rc}: #{c}" }
380
+ end
381
+
382
+ if @stats[:by_tender].any?
383
+ logger.info ""
384
+ logger.info " By Tender:"
385
+ @stats[:by_tender].each { |t, c| logger.info " #{t}: #{c}" }
386
+ end
387
+ end
388
+ end
389
+ end
390
+
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Models
5
+ class ApiRequest < Record
6
+ # Validations
7
+ validates :http_method, presence: true
8
+ validates :url, presence: true
9
+
10
+ # Time scopes
11
+ scope :today, -> { where("created_at >= ?", Time.now.utc.beginning_of_day) }
12
+ scope :recent, ->(minutes = 60) { where("created_at >= ?", minutes.minutes.ago) }
13
+
14
+ # Status scopes
15
+ scope :errors, -> { where("response_status >= 400 OR error_message IS NOT NULL") }
16
+ scope :successful, -> { where("response_status < 400 AND error_message IS NULL") }
17
+
18
+ # Resource scopes
19
+ scope :for_resource, ->(type) { where(resource_type: type) }
20
+ scope :for_resource_id, ->(type, id) { where(resource_type: type, resource_id: id) }
21
+
22
+ # Location scope
23
+ scope :for_location, ->(location_id) {
24
+ sanitized = sanitize_sql_like(location_id)
25
+ where("url LIKE ?", "%/#{sanitized}/%")
26
+ .or(where("url LIKE ?", "%/#{sanitized}"))
27
+ }
28
+
29
+ # HTTP method scopes
30
+ scope :gets, -> { where(http_method: "GET") }
31
+ scope :posts, -> { where(http_method: "POST") }
32
+ scope :puts, -> { where(http_method: "PUT") }
33
+ scope :deletes, -> { where(http_method: "DELETE") }
34
+
35
+ # Performance
36
+ scope :slow, ->(threshold_ms = 1000) { where("duration_ms > ?", threshold_ms) }
37
+
38
+ def error?
39
+ error_message.present? || (response_status && response_status >= 400)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Models
5
+ class BusinessType < Record
6
+ has_many :categories, dependent: :destroy
7
+ has_many :items, through: :categories
8
+ has_many :simulated_orders, dependent: :nullify
9
+
10
+ # Validations
11
+ validates :key, presence: true, uniqueness: true
12
+ validates :name, presence: true
13
+
14
+ # Scopes by industry
15
+ scope :food_types, -> { where(industry: "food") }
16
+ scope :retail_types, -> { where(industry: "retail") }
17
+ scope :service_types, -> { where(industry: "service") }
18
+
19
+ # Find by key (the primary lookup pattern)
20
+ def self.find_by_key!(key)
21
+ find_by!(key: key)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Models
5
+ class Category < Record
6
+ belongs_to :business_type
7
+ has_many :items, dependent: :destroy
8
+
9
+ # Validations
10
+ validates :name, presence: true
11
+
12
+ # Explicit sort scope
13
+ scope :sorted, -> { order(:sort_order) }
14
+ scope :with_items, -> { joins(:items).distinct }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Models
5
+ class DailySummary < Record
6
+ # Validations
7
+ validates :location_id, presence: true
8
+ validates :business_date, presence: true,
9
+ uniqueness: { scope: :location_id }
10
+
11
+ # Scopes
12
+ scope :for_location, ->(location_id) { where(location_id: location_id) }
13
+ scope :on_date, ->(date) { where(business_date: date) }
14
+ scope :today, -> { where(business_date: Date.today) }
15
+ scope :between_dates, ->(from, to) { where(business_date: from..to) }
16
+ scope :recent, ->(days = 7) { where("business_date >= ?", days.days.ago.to_date) }
17
+
18
+ # Generate (or update) a daily summary by aggregating simulated orders.
19
+ # Race-condition safe: retries on unique constraint violation.
20
+ #
21
+ # @param location_id [String] SkyTab location ID
22
+ # @param date [Date] Business date to summarize
23
+ # @return [DailySummary] the created or updated summary
24
+ def self.generate_for!(location_id, date)
25
+ orders = SimulatedOrder.for_location(location_id).on_date(date).successful
26
+ payments = SimulatedPayment.joins(:simulated_order)
27
+ .where(simulated_orders: {
28
+ skytab_location_id: location_id,
29
+ business_date: date,
30
+ status: "closed"
31
+ })
32
+
33
+ # Build breakdown by revenue class and meal period
34
+ breakdown = {
35
+ by_meal_period: orders.group(:meal_period).count,
36
+ by_revenue_class: orders.group(:revenue_class).count,
37
+ by_tender: payments.group(:tender_name).count,
38
+ revenue_by_meal_period: orders.group(:meal_period).sum(:total),
39
+ revenue_by_revenue_class: orders.group(:revenue_class).sum(:total)
40
+ }
41
+
42
+ attrs = {
43
+ order_count: orders.count,
44
+ payment_count: payments.count,
45
+ refund_count: SimulatedOrder.for_location(location_id).on_date(date).refunded.count,
46
+ total_revenue: orders.sum(:total),
47
+ total_tax: orders.sum(:tax_amount),
48
+ total_tips: orders.sum(:tip_amount),
49
+ total_discounts: orders.sum(:discount_amount),
50
+ breakdown: breakdown
51
+ }
52
+
53
+ summary = find_or_initialize_by(location_id: location_id, business_date: date)
54
+ summary.assign_attributes(attrs)
55
+ summary.save!
56
+ summary
57
+ rescue ::ActiveRecord::RecordNotUnique
58
+ retry
59
+ end
60
+
61
+ # Convenience: total revenue in dollars
62
+ def total_revenue_dollars
63
+ (total_revenue || 0) / 100.0
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Models
5
+ class Item < Record
6
+ belongs_to :category
7
+
8
+ # Validations
9
+ validates :name, presence: true
10
+ validates :price, presence: true,
11
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
12
+
13
+ # Scopes
14
+ scope :active, -> { where(active: true) }
15
+ scope :inactive, -> { where(active: false) }
16
+
17
+ scope :for_business_type, ->(key) {
18
+ joins(category: :business_type)
19
+ .where(business_types: { key: key })
20
+ }
21
+
22
+ scope :in_category, ->(category_name) {
23
+ joins(:category).where(categories: { name: category_name })
24
+ }
25
+
26
+ # Price in dollars (convenience)
27
+ def price_dollars
28
+ (price || 0) / 100.0
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module SkytabSandboxSimulator
6
+ module Models
7
+ # Shared base class for all simulator ActiveRecord models.
8
+ #
9
+ # Equivalent to ApplicationRecord in Rails, but for standalone usage.
10
+ class Record < ::ActiveRecord::Base
11
+ self.abstract_class = true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Models
5
+ class SimulatedOrder < Record
6
+ belongs_to :business_type, optional: true
7
+ has_many :simulated_payments, dependent: :destroy
8
+
9
+ # Validations
10
+ validates :skytab_location_id, presence: true
11
+ validates :status, presence: true
12
+ validates :business_date, presence: true
13
+
14
+ # Status scopes
15
+ scope :successful, -> { where(status: "closed") }
16
+ scope :open_orders, -> { where(status: "open") }
17
+ scope :refunded, -> { where(status: "refunded") }
18
+ scope :voided, -> { where(status: "voided") }
19
+
20
+ # Time scopes
21
+ scope :today, -> { where(business_date: Date.today) }
22
+ scope :on_date, ->(date) { where(business_date: date) }
23
+ scope :between_dates, ->(from, to) { where(business_date: from..to) }
24
+
25
+ # Filter scopes
26
+ scope :for_location, ->(location_id) { where(skytab_location_id: location_id) }
27
+ scope :for_revenue_class, ->(revenue_class) { where(revenue_class: revenue_class) }
28
+ scope :for_meal_period, ->(period) { where(meal_period: period) }
29
+
30
+ # Amounts in dollars (convenience)
31
+ def total_dollars
32
+ (total || 0) / 100.0
33
+ end
34
+
35
+ def subtotal_dollars
36
+ (subtotal || 0) / 100.0
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Models
5
+ class SimulatedPayment < Record
6
+ belongs_to :simulated_order
7
+
8
+ # Validations
9
+ validates :tender_name, presence: true
10
+ validates :status, presence: true
11
+ validates :amount, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
12
+
13
+ # Status scopes
14
+ scope :successful, -> { where(status: "completed") }
15
+ scope :pending, -> { where(status: "pending") }
16
+ scope :refunded, -> { where(status: "refunded") }
17
+
18
+ # Tender scopes
19
+ scope :cash, -> { where(tender_name: "Cash") }
20
+ scope :by_tender, ->(name) { where(tender_name: name) }
21
+
22
+ # Amount in dollars (convenience)
23
+ def amount_dollars
24
+ (amount || 0) / 100.0
25
+ end
26
+ end
27
+ end
28
+ end