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.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/bin/simulate +433 -0
- data/lib/skytab_sandbox_simulator/configuration.rb +205 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/items.json +28 -0
- data/lib/skytab_sandbox_simulator/data/bar_nightclub/tenders.json +19 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/items.json +30 -0
- data/lib/skytab_sandbox_simulator/data/cafe_bakery/tenders.json +17 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/items.json +30 -0
- data/lib/skytab_sandbox_simulator/data/fine_dining/tenders.json +18 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/categories.json +9 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/items.json +28 -0
- data/lib/skytab_sandbox_simulator/data/pizzeria/tenders.json +18 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/categories.json +44 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/items.json +59 -0
- data/lib/skytab_sandbox_simulator/data/restaurant/tenders.json +22 -0
- data/lib/skytab_sandbox_simulator/database.rb +192 -0
- data/lib/skytab_sandbox_simulator/db/factories/business_types.rb +102 -0
- data/lib/skytab_sandbox_simulator/db/factories/categories.rb +243 -0
- data/lib/skytab_sandbox_simulator/db/factories/items.rb +976 -0
- data/lib/skytab_sandbox_simulator/db/factories/simulated_orders.rb +120 -0
- data/lib/skytab_sandbox_simulator/db/factories/simulated_payments.rb +75 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000000_enable_pgcrypto.rb +7 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000001_create_business_types.rb +18 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000002_create_categories.rb +18 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000003_create_items.rb +23 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000004_create_simulated_orders.rb +35 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000005_create_simulated_payments.rb +26 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000006_create_api_requests.rb +27 -0
- data/lib/skytab_sandbox_simulator/db/migrate/20260316000007_create_daily_summaries.rb +24 -0
- data/lib/skytab_sandbox_simulator/generators/data_loader.rb +125 -0
- data/lib/skytab_sandbox_simulator/generators/entity_generator.rb +107 -0
- data/lib/skytab_sandbox_simulator/generators/order_generator.rb +390 -0
- data/lib/skytab_sandbox_simulator/models/api_request.rb +43 -0
- data/lib/skytab_sandbox_simulator/models/business_type.rb +25 -0
- data/lib/skytab_sandbox_simulator/models/category.rb +17 -0
- data/lib/skytab_sandbox_simulator/models/daily_summary.rb +67 -0
- data/lib/skytab_sandbox_simulator/models/item.rb +32 -0
- data/lib/skytab_sandbox_simulator/models/record.rb +14 -0
- data/lib/skytab_sandbox_simulator/models/simulated_order.rb +40 -0
- data/lib/skytab_sandbox_simulator/models/simulated_payment.rb +28 -0
- data/lib/skytab_sandbox_simulator/seeder.rb +167 -0
- data/lib/skytab_sandbox_simulator/services/base_service.rb +227 -0
- data/lib/skytab_sandbox_simulator/services/skytab/catalog_service.rb +130 -0
- data/lib/skytab_sandbox_simulator/services/skytab/location_service.rb +54 -0
- data/lib/skytab_sandbox_simulator/services/skytab/order_service.rb +139 -0
- data/lib/skytab_sandbox_simulator/services/skytab/payment_service.rb +94 -0
- data/lib/skytab_sandbox_simulator/services/skytab/service_manager.rb +62 -0
- data/lib/skytab_sandbox_simulator/version.rb +5 -0
- data/lib/skytab_sandbox_simulator.rb +45 -0
- metadata +305 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "factory_bot"
|
|
4
|
+
|
|
5
|
+
module SkytabSandboxSimulator
|
|
6
|
+
# Seeds the database with realistic SkyTab sandbox data using FactoryBot factories.
|
|
7
|
+
#
|
|
8
|
+
# Idempotent — safe to run multiple times without creating duplicates.
|
|
9
|
+
# Uses `find_or_create_by!` on unique keys (BusinessType.key,
|
|
10
|
+
# Category.name+business_type, Item.name+category).
|
|
11
|
+
#
|
|
12
|
+
# @example Seed all business types
|
|
13
|
+
# SkytabSandboxSimulator::Seeder.seed!
|
|
14
|
+
#
|
|
15
|
+
# @example Seed a single business type
|
|
16
|
+
# SkytabSandboxSimulator::Seeder.seed!(business_type: :restaurant)
|
|
17
|
+
#
|
|
18
|
+
class Seeder
|
|
19
|
+
# Maps each business type trait to its category traits,
|
|
20
|
+
# and each category trait to its item traits.
|
|
21
|
+
SEED_MAP = {
|
|
22
|
+
restaurant: {
|
|
23
|
+
appetizers: %i[buffalo_wings mozzarella_sticks loaded_nachos spinach_artichoke_dip calamari
|
|
24
|
+
bruschetta shrimp_cocktail],
|
|
25
|
+
entrees: %i[classic_burger grilled_salmon ny_strip_steak chicken_parmesan fettuccine_alfredo
|
|
26
|
+
fish_and_chips bbq_ribs mushroom_risotto shrimp_scampi],
|
|
27
|
+
sides: %i[french_fries sweet_potato_fries onion_rings coleslaw mashed_potatoes
|
|
28
|
+
steamed_vegetables garlic_bread],
|
|
29
|
+
desserts: %i[chocolate_brownie ny_cheesecake apple_pie tiramisu creme_brulee],
|
|
30
|
+
beverages: %i[soft_drink iced_tea lemonade coffee hot_tea sparkling_water fresh_juice],
|
|
31
|
+
alcohol: %i[draft_beer domestic_beer import_beer house_wine margarita old_fashioned],
|
|
32
|
+
kids_menu: %i[chicken_tenders kids_mac_and_cheese mini_burger grilled_cheese kids_quesadilla],
|
|
33
|
+
specials: %i[chefs_special soup_of_the_day]
|
|
34
|
+
},
|
|
35
|
+
cafe_bakery: {
|
|
36
|
+
coffee_espresso: %i[house_drip_coffee espresso cappuccino latte cold_brew],
|
|
37
|
+
pastries: %i[croissant blueberry_muffin cinnamon_roll chocolate_chip_cookie almond_croissant],
|
|
38
|
+
breakfast_items: %i[avocado_toast breakfast_burrito acai_bowl yogurt_parfait],
|
|
39
|
+
sandwiches_wraps: %i[turkey_club caprese_panini chicken_caesar_wrap blt],
|
|
40
|
+
smoothies_juice: %i[berry_blast_smoothie green_detox_juice mango_tango_smoothie fresh_oj]
|
|
41
|
+
},
|
|
42
|
+
bar_nightclub: {
|
|
43
|
+
draft_beer: %i[bar_house_lager bar_ipa bar_stout bar_wheat_beer],
|
|
44
|
+
cocktails: %i[bar_margarita bar_old_fashioned bar_mojito bar_espresso_martini],
|
|
45
|
+
spirits: %i[whiskey_neat vodka_soda tequila_shot rum_and_coke],
|
|
46
|
+
wine_list: %i[bar_house_red bar_house_white bar_prosecco bar_rose],
|
|
47
|
+
bar_snacks: %i[bar_loaded_fries bar_sliders bar_wings bar_pretzel_bites]
|
|
48
|
+
},
|
|
49
|
+
pizzeria: {
|
|
50
|
+
pizzas: %i[margherita pepperoni supreme hawaiian bbq_chicken_pizza meat_lovers],
|
|
51
|
+
calzones: %i[classic_calzone meat_calzone stromboli spinach_calzone],
|
|
52
|
+
pizza_sides: %i[pizza_garlic_bread pizza_garden_salad pizza_caesar_salad garlic_knots],
|
|
53
|
+
pizza_drinks: %i[pizza_fountain_drink pizza_iced_tea pizza_lemonade],
|
|
54
|
+
pizza_desserts: %i[cannoli pizza_brownie pizza_cheesecake]
|
|
55
|
+
},
|
|
56
|
+
fine_dining: {
|
|
57
|
+
first_course: %i[seared_foie_gras lobster_bisque tuna_tartare burrata_salad
|
|
58
|
+
oysters_half_dozen],
|
|
59
|
+
main_course: %i[wagyu_ribeye chilean_sea_bass rack_of_lamb duck_breast truffle_risotto],
|
|
60
|
+
fine_desserts: %i[fine_creme_brulee chocolate_souffle tasting_plate cheese_board
|
|
61
|
+
fine_tiramisu],
|
|
62
|
+
fine_wines: %i[fine_champagne fine_cabernet fine_pinot_noir fine_chardonnay],
|
|
63
|
+
fine_cocktails: %i[fine_negroni fine_manhattan fine_french_75]
|
|
64
|
+
}
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
# Expected category counts per business type (for spec validation).
|
|
68
|
+
CATEGORY_COUNTS = SEED_MAP.transform_values { |cats| cats.size }.freeze
|
|
69
|
+
|
|
70
|
+
# Expected item counts per category (for spec validation).
|
|
71
|
+
ITEM_COUNTS = SEED_MAP.each_with_object({}) do |(_, cats), hash|
|
|
72
|
+
cats.each { |cat_trait, items| hash[cat_trait] = items.size }
|
|
73
|
+
end.freeze
|
|
74
|
+
|
|
75
|
+
# Total counts across all business types.
|
|
76
|
+
TOTAL_BUSINESS_TYPES = SEED_MAP.size
|
|
77
|
+
TOTAL_CATEGORIES = SEED_MAP.values.sum(&:size)
|
|
78
|
+
TOTAL_ITEMS = SEED_MAP.values.flat_map(&:values).flatten.size
|
|
79
|
+
|
|
80
|
+
# Seed all (or one) business types with categories and items.
|
|
81
|
+
#
|
|
82
|
+
# @param business_type [Symbol, String, nil] Seed only this type, or all if nil.
|
|
83
|
+
# @return [Hash] Summary of created/found counts.
|
|
84
|
+
def self.seed!(business_type: nil)
|
|
85
|
+
new.seed!(business_type: business_type)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param business_type [Symbol, String, nil]
|
|
89
|
+
# @return [Hash] Summary with :business_types, :categories, :items,
|
|
90
|
+
# :created, :found counts.
|
|
91
|
+
def seed!(business_type: nil)
|
|
92
|
+
types_to_seed = resolve_types(business_type)
|
|
93
|
+
|
|
94
|
+
counts = { business_types: 0, categories: 0, items: 0, created: 0, found: 0 }
|
|
95
|
+
|
|
96
|
+
ActiveRecord::Base.transaction do
|
|
97
|
+
types_to_seed.each do |bt_trait, categories_map|
|
|
98
|
+
bt, was_new = seed_business_type(bt_trait)
|
|
99
|
+
counts[:business_types] += 1
|
|
100
|
+
was_new ? counts[:created] += 1 : counts[:found] += 1
|
|
101
|
+
|
|
102
|
+
categories_map.each do |cat_trait, item_traits|
|
|
103
|
+
cat, was_new = seed_category(cat_trait, bt)
|
|
104
|
+
counts[:categories] += 1
|
|
105
|
+
was_new ? counts[:created] += 1 : counts[:found] += 1
|
|
106
|
+
|
|
107
|
+
item_traits.each do |item_trait|
|
|
108
|
+
_, was_new = seed_item(item_trait, cat)
|
|
109
|
+
counts[:items] += 1
|
|
110
|
+
was_new ? counts[:created] += 1 : counts[:found] += 1
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
SkytabSandboxSimulator.logger.info(
|
|
117
|
+
"Seeding complete: #{counts[:business_types]} business types, " \
|
|
118
|
+
"#{counts[:categories]} categories, #{counts[:items]} items " \
|
|
119
|
+
"(#{counts[:created]} created, #{counts[:found]} found)"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
counts
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# Resolve which business types to seed.
|
|
128
|
+
def resolve_types(business_type)
|
|
129
|
+
return SEED_MAP if business_type.nil?
|
|
130
|
+
|
|
131
|
+
key = business_type.to_sym
|
|
132
|
+
unless SEED_MAP.key?(key)
|
|
133
|
+
raise ArgumentError,
|
|
134
|
+
"Unknown business type: #{key}. Valid types: #{SEED_MAP.keys.join(', ')}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
{ key => SEED_MAP[key] }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Find or create a business type using factory attributes.
|
|
141
|
+
def seed_business_type(trait)
|
|
142
|
+
attrs = FactoryBot.attributes_for(:business_type, trait)
|
|
143
|
+
record = Models::BusinessType.find_or_create_by!(key: attrs[:key]) do |bt|
|
|
144
|
+
bt.assign_attributes(attrs)
|
|
145
|
+
end
|
|
146
|
+
[record, record.previously_new_record?]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Find or create a category using factory attributes.
|
|
150
|
+
def seed_category(trait, business_type)
|
|
151
|
+
attrs = FactoryBot.attributes_for(:category, trait)
|
|
152
|
+
record = Models::Category.find_or_create_by!(name: attrs[:name], business_type: business_type) do |cat|
|
|
153
|
+
cat.assign_attributes(attrs.except(:business_type_id))
|
|
154
|
+
end
|
|
155
|
+
[record, record.previously_new_record?]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Find or create an item using factory attributes.
|
|
159
|
+
def seed_item(trait, category)
|
|
160
|
+
attrs = FactoryBot.attributes_for(:item, trait)
|
|
161
|
+
record = Models::Item.find_or_create_by!(name: attrs[:name], category: category) do |item|
|
|
162
|
+
item.assign_attributes(attrs.except(:category_id))
|
|
163
|
+
end
|
|
164
|
+
[record, record.previously_new_record?]
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
# Base service for all SkyTab API interactions.
|
|
6
|
+
# Provides HTTP client, logging, and error handling.
|
|
7
|
+
class BaseService
|
|
8
|
+
attr_reader :config, :logger
|
|
9
|
+
|
|
10
|
+
def initialize(config: nil)
|
|
11
|
+
@config = config || SkytabSandboxSimulator.configuration
|
|
12
|
+
@config.validate!
|
|
13
|
+
@logger = @config.logger
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
protected
|
|
17
|
+
|
|
18
|
+
# Make HTTP request to SkyTab (Shift4) API
|
|
19
|
+
#
|
|
20
|
+
# Every call is audit-logged to the `api_requests` table when a
|
|
21
|
+
# database connection is available. If no DB is connected the
|
|
22
|
+
# request still executes normally.
|
|
23
|
+
#
|
|
24
|
+
# @param method [Symbol] HTTP method (:get, :post, :put, :delete)
|
|
25
|
+
# @param path [String] API endpoint path
|
|
26
|
+
# @param payload [Hash, nil] Request body for POST/PUT
|
|
27
|
+
# @param params [Hash, nil] Query parameters
|
|
28
|
+
# @param resource_type [String, nil] Logical resource (e.g. "Ticket")
|
|
29
|
+
# @param resource_id [String, nil] Resource ID
|
|
30
|
+
# @return [Hash, nil] Parsed JSON response
|
|
31
|
+
def request(method, path, payload: nil, params: nil, resource_type: nil, resource_id: nil)
|
|
32
|
+
url = build_url(path, params)
|
|
33
|
+
|
|
34
|
+
log_request(method, url, payload)
|
|
35
|
+
start_time = Time.now
|
|
36
|
+
|
|
37
|
+
response = execute_request(method, url, payload)
|
|
38
|
+
|
|
39
|
+
duration_ms = ((Time.now - start_time) * 1000).round
|
|
40
|
+
log_response(response, duration_ms)
|
|
41
|
+
|
|
42
|
+
parsed = parse_response(response)
|
|
43
|
+
|
|
44
|
+
audit_api_request(
|
|
45
|
+
http_method: method.to_s.upcase,
|
|
46
|
+
url: url,
|
|
47
|
+
request_payload: payload,
|
|
48
|
+
response_status: response.code,
|
|
49
|
+
response_payload: parsed,
|
|
50
|
+
duration_ms: duration_ms,
|
|
51
|
+
resource_type: resource_type,
|
|
52
|
+
resource_id: resource_id
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
parsed
|
|
56
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
57
|
+
duration_ms = ((Time.now - start_time) * 1000).round if start_time
|
|
58
|
+
|
|
59
|
+
audit_api_request(
|
|
60
|
+
http_method: method.to_s.upcase,
|
|
61
|
+
url: url,
|
|
62
|
+
request_payload: payload,
|
|
63
|
+
response_status: e.http_code,
|
|
64
|
+
response_payload: (JSON.parse(e.response.body) rescue nil),
|
|
65
|
+
duration_ms: duration_ms,
|
|
66
|
+
error_message: "HTTP #{e.http_code}: #{e.message}",
|
|
67
|
+
resource_type: resource_type,
|
|
68
|
+
resource_id: resource_id
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
handle_api_error(e)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
duration_ms = ((Time.now - start_time) * 1000).round if start_time
|
|
74
|
+
|
|
75
|
+
audit_api_request(
|
|
76
|
+
http_method: method.to_s.upcase,
|
|
77
|
+
url: url,
|
|
78
|
+
request_payload: payload,
|
|
79
|
+
duration_ms: duration_ms,
|
|
80
|
+
error_message: e.message,
|
|
81
|
+
resource_type: resource_type,
|
|
82
|
+
resource_id: resource_id
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
logger.error "Request failed: #{e.message}"
|
|
86
|
+
raise ApiError, e.message
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Build Marketplace API endpoint path
|
|
90
|
+
#
|
|
91
|
+
# @param path [String] Relative path
|
|
92
|
+
# @return [String] Full endpoint path
|
|
93
|
+
def marketplace_endpoint(path)
|
|
94
|
+
"marketplace/v2/#{path}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Build POS API endpoint path for a location
|
|
98
|
+
#
|
|
99
|
+
# @param path [String] Relative path after location ID
|
|
100
|
+
# @return [String] Full endpoint path
|
|
101
|
+
def pos_endpoint(path)
|
|
102
|
+
"pos/v2/#{config.location_id}/#{path}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def headers
|
|
108
|
+
{
|
|
109
|
+
"Authorization" => "Bearer #{config.access_token}",
|
|
110
|
+
"Content-Type" => "application/json",
|
|
111
|
+
"Accept" => "application/json"
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_url(path, params = nil)
|
|
116
|
+
base = path.start_with?("http") ? path : "#{config.environment}#{path}"
|
|
117
|
+
return base unless params&.any?
|
|
118
|
+
|
|
119
|
+
uri = URI(base)
|
|
120
|
+
uri.query = URI.encode_www_form(params)
|
|
121
|
+
uri.to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def execute_request(method, url, payload)
|
|
125
|
+
case method
|
|
126
|
+
when :get then RestClient.get(url, headers)
|
|
127
|
+
when :post then RestClient.post(url, payload&.to_json, headers)
|
|
128
|
+
when :put then RestClient.put(url, payload&.to_json, headers)
|
|
129
|
+
when :delete then RestClient.delete(url, headers)
|
|
130
|
+
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def parse_response(response)
|
|
135
|
+
return nil if response.body.nil? || response.body.empty?
|
|
136
|
+
|
|
137
|
+
JSON.parse(response.body)
|
|
138
|
+
rescue JSON::ParserError => e
|
|
139
|
+
logger.error "Failed to parse response: #{e.message}"
|
|
140
|
+
raise ApiError, "Invalid JSON response"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def handle_api_error(error)
|
|
144
|
+
body = begin
|
|
145
|
+
JSON.parse(error.response.body)
|
|
146
|
+
rescue StandardError
|
|
147
|
+
{ "message" => error.response.body }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
logger.error "API Error (#{error.http_code}): #{body}"
|
|
151
|
+
raise ApiError, "HTTP #{error.http_code}: #{body["message"] || body}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def log_request(method, url, payload)
|
|
155
|
+
logger.debug "-> #{method.to_s.upcase} #{url}"
|
|
156
|
+
logger.debug " Payload: #{payload.inspect}" if payload
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def log_response(response, duration_ms)
|
|
160
|
+
logger.debug "<- #{response.code} (#{duration_ms}ms)"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Persist an API request record for audit trail.
|
|
164
|
+
# Silently no-ops when DB is not connected.
|
|
165
|
+
def audit_api_request(http_method:, url:, request_payload: nil, response_status: nil,
|
|
166
|
+
response_payload: nil, duration_ms: nil, error_message: nil,
|
|
167
|
+
resource_type: nil, resource_id: nil)
|
|
168
|
+
return unless Database.connected?
|
|
169
|
+
|
|
170
|
+
attrs = {
|
|
171
|
+
http_method: http_method,
|
|
172
|
+
url: url,
|
|
173
|
+
request_payload: request_payload || {},
|
|
174
|
+
response_payload: response_payload || {},
|
|
175
|
+
response_status: response_status,
|
|
176
|
+
duration_ms: duration_ms,
|
|
177
|
+
error_message: error_message,
|
|
178
|
+
resource_type: resource_type,
|
|
179
|
+
resource_id: resource_id
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Tag with the current location when the column exists
|
|
183
|
+
if config&.location_id.present? && Models::ApiRequest.column_names.include?("skytab_location_id")
|
|
184
|
+
attrs[:skytab_location_id] = config.location_id
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
Models::ApiRequest.create!(attrs)
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
logger.debug "Audit logging failed: #{e.message}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Execute a block with API error fallback
|
|
193
|
+
def with_api_fallback(fallback: nil, log_level: :debug, reraise_on: [])
|
|
194
|
+
yield
|
|
195
|
+
rescue ApiError => e
|
|
196
|
+
raise if reraise_on.any? { |code| e.message.include?("HTTP #{code}") }
|
|
197
|
+
|
|
198
|
+
logger.send(log_level, "API error (using fallback): #{e.message}")
|
|
199
|
+
fallback
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
logger.send(log_level, "Error (using fallback): #{e.message}")
|
|
202
|
+
fallback
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Execute a block, handling sandbox limitations (405 errors)
|
|
206
|
+
def with_sandbox_fallback(simulated_response: nil)
|
|
207
|
+
yield
|
|
208
|
+
rescue ApiError => e
|
|
209
|
+
if e.message.include?("405")
|
|
210
|
+
logger.warn "Operation not supported in sandbox environment"
|
|
211
|
+
simulated_response
|
|
212
|
+
else
|
|
213
|
+
raise
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Safe getter for nested hash values with logging
|
|
218
|
+
def safe_dig(hash, *keys, default: nil)
|
|
219
|
+
return default if hash.nil?
|
|
220
|
+
|
|
221
|
+
hash.dig(*keys) || default
|
|
222
|
+
rescue StandardError
|
|
223
|
+
default
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
module Skytab
|
|
6
|
+
# Manages SkyTab catalog: categories, items, taxes, modifier sets.
|
|
7
|
+
# GET /pos/v2/{locationId}/menu
|
|
8
|
+
class CatalogService < BaseService
|
|
9
|
+
# Fetch the full menu for the current location
|
|
10
|
+
#
|
|
11
|
+
# @return [Hash] Menu data with categories, items, taxes, modifierSets
|
|
12
|
+
def get_menu
|
|
13
|
+
logger.info "Fetching menu..."
|
|
14
|
+
response = request(:get, pos_endpoint("menu"),
|
|
15
|
+
resource_type: "Menu")
|
|
16
|
+
logger.info "Menu fetched successfully"
|
|
17
|
+
response || {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fetch all categories from the menu
|
|
21
|
+
#
|
|
22
|
+
# @return [Array<Hash>] Array of category objects
|
|
23
|
+
def get_categories
|
|
24
|
+
logger.info "Fetching categories..."
|
|
25
|
+
menu = get_menu
|
|
26
|
+
categories = menu["categories"] || []
|
|
27
|
+
logger.info "Found #{categories.size} categories"
|
|
28
|
+
categories
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fetch all items from the menu
|
|
32
|
+
#
|
|
33
|
+
# @return [Array<Hash>] Array of item objects
|
|
34
|
+
def get_items
|
|
35
|
+
logger.info "Fetching items..."
|
|
36
|
+
menu = get_menu
|
|
37
|
+
items = menu["items"] || []
|
|
38
|
+
logger.info "Found #{items.size} items"
|
|
39
|
+
items
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fetch all tax rates from the menu
|
|
43
|
+
#
|
|
44
|
+
# @return [Array<Hash>] Array of tax rate objects
|
|
45
|
+
def get_taxes
|
|
46
|
+
logger.info "Fetching taxes..."
|
|
47
|
+
menu = get_menu
|
|
48
|
+
taxes = menu["taxes"] || []
|
|
49
|
+
logger.info "Found #{taxes.size} tax rates"
|
|
50
|
+
taxes
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Fetch all modifier sets from the menu
|
|
54
|
+
#
|
|
55
|
+
# @return [Array<Hash>] Array of modifier set objects
|
|
56
|
+
def get_modifier_sets
|
|
57
|
+
logger.info "Fetching modifier sets..."
|
|
58
|
+
menu = get_menu
|
|
59
|
+
modifier_sets = menu["modifierSets"] || []
|
|
60
|
+
logger.info "Found #{modifier_sets.size} modifier sets"
|
|
61
|
+
modifier_sets
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Create a category via POS API
|
|
65
|
+
#
|
|
66
|
+
# @param name [String] Category name
|
|
67
|
+
# @param sort_order [Integer, nil] Display sort order
|
|
68
|
+
# @return [Hash, nil] Created category
|
|
69
|
+
def create_category(name:, sort_order: nil)
|
|
70
|
+
logger.info "Creating category: #{name}"
|
|
71
|
+
payload = { "name" => name }
|
|
72
|
+
payload["sortOrder"] = sort_order if sort_order
|
|
73
|
+
request(:post, pos_endpoint("categories"), payload: payload,
|
|
74
|
+
resource_type: "Category")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Create an item via POS API
|
|
78
|
+
#
|
|
79
|
+
# @param name [String] Item name
|
|
80
|
+
# @param price [Integer] Price in cents
|
|
81
|
+
# @param category_id [String, nil] Category to associate with
|
|
82
|
+
# @param sku [String, nil] SKU identifier
|
|
83
|
+
# @return [Hash, nil] Created item
|
|
84
|
+
def create_item(name:, price:, category_id: nil, sku: nil)
|
|
85
|
+
logger.info "Creating item: #{name} ($#{price / 100.0})"
|
|
86
|
+
|
|
87
|
+
payload = {
|
|
88
|
+
"name" => name,
|
|
89
|
+
"price" => price
|
|
90
|
+
}
|
|
91
|
+
payload["categoryId"] = category_id if category_id
|
|
92
|
+
payload["sku"] = sku if sku
|
|
93
|
+
|
|
94
|
+
request(:post, pos_endpoint("items"), payload: payload,
|
|
95
|
+
resource_type: "Item")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Delete a category
|
|
99
|
+
#
|
|
100
|
+
# @param category_id [String] Category ID to delete
|
|
101
|
+
# @return [Hash, nil] Response
|
|
102
|
+
def delete_category(category_id)
|
|
103
|
+
logger.info "Deleting category: #{category_id}"
|
|
104
|
+
request(:delete, pos_endpoint("categories/#{category_id}"),
|
|
105
|
+
resource_type: "Category", resource_id: category_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Delete an item
|
|
109
|
+
#
|
|
110
|
+
# @param item_id [String] Item ID to delete
|
|
111
|
+
# @return [Hash, nil] Response
|
|
112
|
+
def delete_item(item_id)
|
|
113
|
+
logger.info "Deleting item: #{item_id}"
|
|
114
|
+
request(:delete, pos_endpoint("items/#{item_id}"),
|
|
115
|
+
resource_type: "Item", resource_id: item_id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Delete all categories and items
|
|
119
|
+
def delete_all
|
|
120
|
+
logger.warn "Deleting all catalog data..."
|
|
121
|
+
|
|
122
|
+
get_items.each { |item| delete_item(item["id"]) }
|
|
123
|
+
get_categories.each { |cat| delete_category(cat["id"]) }
|
|
124
|
+
|
|
125
|
+
logger.info "All catalog data deleted"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SkytabSandboxSimulator
|
|
4
|
+
module Services
|
|
5
|
+
module Skytab
|
|
6
|
+
# Manages SkyTab location information via Shift4 Marketplace API.
|
|
7
|
+
# GET /marketplace/v2/locations
|
|
8
|
+
class LocationService < BaseService
|
|
9
|
+
# Fetch all locations for the authenticated merchant
|
|
10
|
+
#
|
|
11
|
+
# @return [Array<Hash>] Array of location objects
|
|
12
|
+
def get_locations
|
|
13
|
+
logger.info "Fetching locations..."
|
|
14
|
+
response = request(:get, marketplace_endpoint("locations"),
|
|
15
|
+
resource_type: "Location")
|
|
16
|
+
locations = extract_locations(response)
|
|
17
|
+
logger.info "Found #{locations.size} locations"
|
|
18
|
+
locations
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Fetch a single location by ID
|
|
22
|
+
#
|
|
23
|
+
# @param location_id [String] Location ID
|
|
24
|
+
# @return [Hash, nil] Location data
|
|
25
|
+
def get_location(location_id)
|
|
26
|
+
logger.info "Fetching location: #{location_id}"
|
|
27
|
+
locations = get_locations
|
|
28
|
+
locations.find { |loc| loc["id"].to_s == location_id.to_s }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Fetch the current configured location
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash, nil] Location data
|
|
34
|
+
def get_current_location
|
|
35
|
+
get_location(config.location_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def extract_locations(response)
|
|
41
|
+
return [] if response.nil?
|
|
42
|
+
|
|
43
|
+
if response.is_a?(Array)
|
|
44
|
+
response
|
|
45
|
+
elsif response.is_a?(Hash)
|
|
46
|
+
response["locations"] || response["elements"] || [response]
|
|
47
|
+
else
|
|
48
|
+
[]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|