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.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +380 -0
- data/bin/simulate +309 -0
- data/lib/epos_now_sandbox_simulator/configuration.rb +173 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/items.json +26 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/items.json +28 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/items.json +29 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/tenders.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/items.json +17 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/database.rb +136 -0
- data/lib/epos_now_sandbox_simulator/db/factories/api_requests.rb +13 -0
- data/lib/epos_now_sandbox_simulator/db/factories/business_types.rb +34 -0
- data/lib/epos_now_sandbox_simulator/db/factories/categories.rb +10 -0
- data/lib/epos_now_sandbox_simulator/db/factories/items.rb +12 -0
- data/lib/epos_now_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
- data/lib/epos_now_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000001_enable_pgcrypto.rb +7 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000002_create_business_types.rb +16 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000003_create_categories.rb +16 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000004_create_items.rb +19 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000005_create_simulated_orders.rb +27 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000006_create_simulated_payments.rb +22 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000007_create_api_requests.rb +22 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000008_create_daily_summaries.rb +21 -0
- data/lib/epos_now_sandbox_simulator/generators/data_loader.rb +100 -0
- data/lib/epos_now_sandbox_simulator/generators/entity_generator.rb +103 -0
- data/lib/epos_now_sandbox_simulator/generators/order_generator.rb +336 -0
- data/lib/epos_now_sandbox_simulator/models/api_request.rb +16 -0
- data/lib/epos_now_sandbox_simulator/models/business_type.rb +16 -0
- data/lib/epos_now_sandbox_simulator/models/category.rb +18 -0
- data/lib/epos_now_sandbox_simulator/models/daily_summary.rb +43 -0
- data/lib/epos_now_sandbox_simulator/models/item.rb +20 -0
- data/lib/epos_now_sandbox_simulator/models/simulated_order.rb +21 -0
- data/lib/epos_now_sandbox_simulator/models/simulated_payment.rb +17 -0
- data/lib/epos_now_sandbox_simulator/seeder.rb +119 -0
- data/lib/epos_now_sandbox_simulator/services/base_service.rb +248 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/inventory_service.rb +178 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/services_manager.rb +56 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/tax_service.rb +45 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/tender_service.rb +90 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/transaction_service.rb +171 -0
- data/lib/epos_now_sandbox_simulator.rb +49 -0
- 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
|