square_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/LICENSE.txt +21 -0
- data/README.md +176 -0
- data/bin/simulate +388 -0
- data/lib/square_sandbox_simulator/configuration.rb +193 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/categories.json +54 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/combos.json +33 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/coupon_codes.json +133 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/discounts.json +113 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/items.json +55 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/modifiers.json +73 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/tax_rates.json +26 -0
- data/lib/square_sandbox_simulator/data/cafe_bakery/tenders.json +41 -0
- data/lib/square_sandbox_simulator/data/restaurant/categories.json +54 -0
- data/lib/square_sandbox_simulator/data/restaurant/combos.json +265 -0
- data/lib/square_sandbox_simulator/data/restaurant/coupon_codes.json +266 -0
- data/lib/square_sandbox_simulator/data/restaurant/discounts.json +198 -0
- data/lib/square_sandbox_simulator/data/restaurant/gift_cards.json +82 -0
- data/lib/square_sandbox_simulator/data/restaurant/items.json +388 -0
- data/lib/square_sandbox_simulator/data/restaurant/modifiers.json +62 -0
- data/lib/square_sandbox_simulator/data/restaurant/tax_rates.json +38 -0
- data/lib/square_sandbox_simulator/data/restaurant/tenders.json +41 -0
- data/lib/square_sandbox_simulator/data/salon_spa/categories.json +24 -0
- data/lib/square_sandbox_simulator/data/salon_spa/combos.json +88 -0
- data/lib/square_sandbox_simulator/data/salon_spa/coupon_codes.json +96 -0
- data/lib/square_sandbox_simulator/data/salon_spa/discounts.json +93 -0
- data/lib/square_sandbox_simulator/data/salon_spa/gift_cards.json +47 -0
- data/lib/square_sandbox_simulator/data/salon_spa/items.json +100 -0
- data/lib/square_sandbox_simulator/data/salon_spa/modifiers.json +49 -0
- data/lib/square_sandbox_simulator/data/salon_spa/tax_rates.json +17 -0
- data/lib/square_sandbox_simulator/data/salon_spa/tenders.json +41 -0
- data/lib/square_sandbox_simulator/database.rb +224 -0
- data/lib/square_sandbox_simulator/db/factories/api_requests.rb +95 -0
- data/lib/square_sandbox_simulator/db/factories/business_types.rb +178 -0
- data/lib/square_sandbox_simulator/db/factories/categories.rb +379 -0
- data/lib/square_sandbox_simulator/db/factories/daily_summaries.rb +56 -0
- data/lib/square_sandbox_simulator/db/factories/items.rb +1526 -0
- data/lib/square_sandbox_simulator/db/factories/simulated_orders.rb +112 -0
- data/lib/square_sandbox_simulator/db/factories/simulated_payments.rb +61 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000000_enable_pgcrypto.rb +7 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000001_create_business_types.rb +18 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000002_create_categories.rb +18 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000003_create_items.rb +23 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000004_create_simulated_orders.rb +36 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000005_create_simulated_payments.rb +26 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000006_create_api_requests.rb +27 -0
- data/lib/square_sandbox_simulator/db/migrate/20260312000007_create_daily_summaries.rb +24 -0
- data/lib/square_sandbox_simulator/generators/data_loader.rb +202 -0
- data/lib/square_sandbox_simulator/generators/entity_generator.rb +248 -0
- data/lib/square_sandbox_simulator/generators/order_generator.rb +632 -0
- data/lib/square_sandbox_simulator/models/api_request.rb +43 -0
- data/lib/square_sandbox_simulator/models/business_type.rb +25 -0
- data/lib/square_sandbox_simulator/models/category.rb +18 -0
- data/lib/square_sandbox_simulator/models/daily_summary.rb +68 -0
- data/lib/square_sandbox_simulator/models/item.rb +33 -0
- data/lib/square_sandbox_simulator/models/record.rb +16 -0
- data/lib/square_sandbox_simulator/models/simulated_order.rb +42 -0
- data/lib/square_sandbox_simulator/models/simulated_payment.rb +28 -0
- data/lib/square_sandbox_simulator/seeder.rb +242 -0
- data/lib/square_sandbox_simulator/services/base_service.rb +253 -0
- data/lib/square_sandbox_simulator/services/square/catalog_service.rb +203 -0
- data/lib/square_sandbox_simulator/services/square/customer_service.rb +130 -0
- data/lib/square_sandbox_simulator/services/square/order_service.rb +121 -0
- data/lib/square_sandbox_simulator/services/square/payment_service.rb +136 -0
- data/lib/square_sandbox_simulator/services/square/services_manager.rb +68 -0
- data/lib/square_sandbox_simulator/services/square/team_service.rb +108 -0
- data/lib/square_sandbox_simulator/version.rb +5 -0
- data/lib/square_sandbox_simulator.rb +47 -0
- metadata +348 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SquareSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class Category < Record
|
|
6
|
+
belongs_to :business_type
|
|
7
|
+
has_many :items, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
# Validations — no uniqueness constraint; real Square merchants
|
|
10
|
+
# frequently have duplicate category names.
|
|
11
|
+
validates :name, presence: true
|
|
12
|
+
|
|
13
|
+
# Explicit sort scope (avoids default_scope anti-pattern)
|
|
14
|
+
scope :sorted, -> { order(:sort_order) }
|
|
15
|
+
scope :with_items, -> { joins(:items).distinct }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SquareSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class DailySummary < Record
|
|
6
|
+
# Validations
|
|
7
|
+
validates :merchant_id, presence: true
|
|
8
|
+
validates :business_date, presence: true,
|
|
9
|
+
uniqueness: { scope: :merchant_id }
|
|
10
|
+
|
|
11
|
+
# Scopes
|
|
12
|
+
scope :for_merchant, ->(merchant_id) { where(merchant_id: merchant_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 merchant_id [String] Square merchant ID
|
|
22
|
+
# @param date [Date] Business date to summarize
|
|
23
|
+
# @return [DailySummary] the created or updated summary
|
|
24
|
+
def self.generate_for!(merchant_id, date)
|
|
25
|
+
orders = SimulatedOrder.for_location(merchant_id).on_date(date).successful
|
|
26
|
+
payments = SimulatedPayment.joins(:simulated_order)
|
|
27
|
+
.where(simulated_orders: {
|
|
28
|
+
square_location_id: merchant_id,
|
|
29
|
+
business_date: date,
|
|
30
|
+
status: "paid",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
# Build breakdown by meal period and dining option
|
|
34
|
+
breakdown = {
|
|
35
|
+
by_meal_period: orders.group(:meal_period).count,
|
|
36
|
+
by_dining_option: orders.group(:dining_option).count,
|
|
37
|
+
by_tender: payments.group(:tender_name).count,
|
|
38
|
+
revenue_by_meal_period: orders.group(:meal_period).sum(:total),
|
|
39
|
+
revenue_by_dining_option: orders.group(:dining_option).sum(:total),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
attrs = {
|
|
43
|
+
order_count: orders.count,
|
|
44
|
+
payment_count: payments.count,
|
|
45
|
+
refund_count: SimulatedOrder.for_location(merchant_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(merchant_id: merchant_id, business_date: date)
|
|
54
|
+
summary.assign_attributes(attrs)
|
|
55
|
+
summary.save!
|
|
56
|
+
summary
|
|
57
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
58
|
+
# Concurrent insert won — retry will find the existing record and update it
|
|
59
|
+
retry
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Convenience: total revenue in dollars
|
|
63
|
+
def total_revenue_dollars
|
|
64
|
+
(total_revenue || 0) / 100.0
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SquareSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class Item < Record
|
|
6
|
+
belongs_to :category
|
|
7
|
+
|
|
8
|
+
# Validations — no uniqueness constraint; real Square merchants
|
|
9
|
+
# can have items with the same name in the same category.
|
|
10
|
+
validates :name, presence: true
|
|
11
|
+
validates :price, presence: true,
|
|
12
|
+
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
|
13
|
+
|
|
14
|
+
# Scopes
|
|
15
|
+
scope :active, -> { where(active: true) }
|
|
16
|
+
scope :inactive, -> { where(active: false) }
|
|
17
|
+
|
|
18
|
+
scope :for_business_type, lambda { |key|
|
|
19
|
+
joins(category: :business_type)
|
|
20
|
+
.where(business_types: { key: key })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
scope :in_category, lambda { |category_name|
|
|
24
|
+
joins(:category).where(categories: { name: category_name })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
# Price in dollars (convenience)
|
|
28
|
+
def price_dollars
|
|
29
|
+
(price || 0) / 100.0
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
|
|
5
|
+
module SquareSandboxSimulator
|
|
6
|
+
module Models
|
|
7
|
+
# Shared base class for all simulator ActiveRecord models.
|
|
8
|
+
#
|
|
9
|
+
# Equivalent to ApplicationRecord in Rails, but for standalone usage.
|
|
10
|
+
# All models inherit from this so we can add shared behaviour
|
|
11
|
+
# (e.g. logging, default scopes) in one place.
|
|
12
|
+
class Record < ::ActiveRecord::Base
|
|
13
|
+
self.abstract_class = true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SquareSandboxSimulator
|
|
4
|
+
module Models
|
|
5
|
+
class SimulatedOrder < Record
|
|
6
|
+
belongs_to :business_type, optional: true
|
|
7
|
+
has_many :simulated_payments, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
# NOTE: no has_many :items — simulated orders track monetary totals only.
|
|
10
|
+
# Line-item detail lives in the Square API response (stored in metadata jsonb).
|
|
11
|
+
|
|
12
|
+
# Validations
|
|
13
|
+
validates :square_location_id, presence: true
|
|
14
|
+
validates :status, presence: true
|
|
15
|
+
validates :business_date, presence: true
|
|
16
|
+
|
|
17
|
+
# Status scopes
|
|
18
|
+
scope :successful, -> { where(status: "paid") }
|
|
19
|
+
scope :open_orders, -> { where(status: "open") }
|
|
20
|
+
scope :refunded, -> { where(status: "refunded") }
|
|
21
|
+
|
|
22
|
+
# Time scopes
|
|
23
|
+
scope :today, -> { where(business_date: Date.today) }
|
|
24
|
+
scope :on_date, ->(date) { where(business_date: date) }
|
|
25
|
+
scope :between_dates, ->(from, to) { where(business_date: from..to) }
|
|
26
|
+
|
|
27
|
+
# Filter scopes
|
|
28
|
+
scope :for_location, ->(location_id) { where(square_location_id: location_id) }
|
|
29
|
+
scope :for_meal_period, ->(period) { where(meal_period: period) }
|
|
30
|
+
scope :for_dining_option, ->(option) { where(dining_option: option) }
|
|
31
|
+
|
|
32
|
+
# Amounts in dollars (convenience)
|
|
33
|
+
def total_dollars
|
|
34
|
+
(total || 0) / 100.0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def subtotal_dollars
|
|
38
|
+
(subtotal || 0) / 100.0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SquareSandboxSimulator
|
|
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: "SUCCESS") }
|
|
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
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "factory_bot"
|
|
4
|
+
|
|
5
|
+
module SquareSandboxSimulator
|
|
6
|
+
# Seeds the database with realistic Square 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
|
+
# SquareSandboxSimulator::Seeder.seed!
|
|
14
|
+
#
|
|
15
|
+
# @example Seed a single business type
|
|
16
|
+
# SquareSandboxSimulator::Seeder.seed!(business_type: :retail_clothing)
|
|
17
|
+
#
|
|
18
|
+
class Seeder
|
|
19
|
+
# Maps each business type trait to its category traits,
|
|
20
|
+
# and each category trait to its item traits.
|
|
21
|
+
#
|
|
22
|
+
# Structure: { bt_trait => { cat_trait => [item_traits] } }
|
|
23
|
+
SEED_MAP = {
|
|
24
|
+
restaurant: {
|
|
25
|
+
appetizers: %i[buffalo_wings mozzarella_sticks loaded_nachos spinach_artichoke_dip calamari],
|
|
26
|
+
entrees: %i[grilled_salmon ny_strip_steak chicken_parmesan pasta_primavera herb_roasted_chicken],
|
|
27
|
+
sides: %i[french_fries coleslaw mashed_potatoes side_salad onion_rings],
|
|
28
|
+
desserts: %i[chocolate_lava_cake ny_cheesecake restaurant_tiramisu apple_pie ice_cream_sundae],
|
|
29
|
+
beverages: %i[soft_drink iced_tea lemonade sparkling_water restaurant_coffee],
|
|
30
|
+
},
|
|
31
|
+
cafe_bakery: {
|
|
32
|
+
coffee_espresso: %i[house_drip_coffee espresso cappuccino latte cold_brew],
|
|
33
|
+
pastries: %i[croissant blueberry_muffin cinnamon_roll chocolate_chip_cookie],
|
|
34
|
+
breakfast: %i[avocado_toast breakfast_burrito acai_bowl yogurt_parfait],
|
|
35
|
+
sandwiches: %i[turkey_club caprese_panini chicken_caesar_wrap blt],
|
|
36
|
+
smoothies: %i[berry_blast_smoothie green_detox_juice mango_tango_smoothie fresh_oj],
|
|
37
|
+
# Intentional duplicates (mirrors real POS messiness)
|
|
38
|
+
coffee_espresso_dup: %i[house_drip_coffee_dup latte_dup iced_latte matcha_latte],
|
|
39
|
+
pastries_dup: %i[croissant_dup blueberry_muffin_dup almond_croissant banana_bread],
|
|
40
|
+
breakfast_dup: %i[avocado_toast_dup eggs_benedict french_toast],
|
|
41
|
+
cafe_drinks: %i[cafe_drip_coffee cafe_iced_tea cafe_lemonade cafe_hot_chocolate berry_blast_smoothie_dup],
|
|
42
|
+
cafe_grab_and_go: %i[gg_croissant gg_muffin gg_blt gg_yogurt_parfait gg_cold_brew],
|
|
43
|
+
},
|
|
44
|
+
bar_nightclub: {
|
|
45
|
+
draft_beer: %i[house_lager ipa stout wheat_beer],
|
|
46
|
+
cocktails: %i[margarita old_fashioned mojito espresso_martini],
|
|
47
|
+
spirits: %i[whiskey_neat vodka_soda tequila_shot rum_and_coke],
|
|
48
|
+
wine: %i[house_red house_white prosecco rose],
|
|
49
|
+
bar_snacks: %i[bar_loaded_fries sliders bar_wings pretzel_bites],
|
|
50
|
+
},
|
|
51
|
+
food_truck: {
|
|
52
|
+
tacos: %i[carne_asada_taco al_pastor_taco fish_taco veggie_taco birria_taco],
|
|
53
|
+
burritos_bowls: %i[classic_burrito burrito_bowl quesadilla truck_nachos],
|
|
54
|
+
truck_sides_drinks: %i[chips_and_guac elote rice_and_beans horchata jarritos],
|
|
55
|
+
},
|
|
56
|
+
fine_dining: {
|
|
57
|
+
first_course: %i[seared_foie_gras lobster_bisque tuna_tartare burrata_salad oysters_half_dozen],
|
|
58
|
+
main_course: %i[wagyu_ribeye chilean_sea_bass rack_of_lamb duck_breast truffle_risotto],
|
|
59
|
+
fine_desserts: %i[creme_brulee chocolate_souffle tasting_plate cheese_board fine_tiramisu],
|
|
60
|
+
},
|
|
61
|
+
pizzeria: {
|
|
62
|
+
pizzas: %i[margherita pepperoni supreme hawaiian bbq_chicken_pizza meat_lovers],
|
|
63
|
+
calzones: %i[classic_calzone meat_calzone stromboli spinach_calzone],
|
|
64
|
+
pizza_sides_drinks: %i[garlic_bread garden_salad caesar_salad garlic_knots fountain_drink cannoli],
|
|
65
|
+
},
|
|
66
|
+
retail_clothing: {
|
|
67
|
+
tops: %i[classic_tshirt button_down_shirt polo_shirt hoodie tank_top],
|
|
68
|
+
bottoms: %i[slim_fit_jeans chino_pants joggers shorts],
|
|
69
|
+
outerwear: %i[denim_jacket bomber_jacket puffer_vest rain_jacket],
|
|
70
|
+
accessories: %i[baseball_cap beanie canvas_belt scarf],
|
|
71
|
+
footwear: %i[canvas_sneakers leather_boots sandals running_shoes],
|
|
72
|
+
},
|
|
73
|
+
retail_general: {
|
|
74
|
+
electronics: %i[wireless_earbuds phone_charger bluetooth_speaker power_bank],
|
|
75
|
+
home_kitchen: %i[scented_candle throw_pillow kitchen_towel_set ceramic_mug],
|
|
76
|
+
personal_care: %i[hand_soap body_lotion lip_balm sunscreen],
|
|
77
|
+
office_supplies: %i[notebook pen_set desk_organizer sticky_notes],
|
|
78
|
+
snacks_beverages: %i[granola_bar_3pack retail_sparkling_water trail_mix dark_chocolate_bar],
|
|
79
|
+
},
|
|
80
|
+
salon_spa: {
|
|
81
|
+
haircuts: %i[womens_haircut mens_haircut childrens_haircut bang_trim],
|
|
82
|
+
color_services: %i[full_color partial_highlights full_highlights balayage],
|
|
83
|
+
spa_treatments: %i[swedish_massage deep_tissue_massage hot_stone_massage facial],
|
|
84
|
+
nail_services: %i[manicure pedicure gel_manicure acrylic_full_set],
|
|
85
|
+
},
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# Expected category counts per business type (for spec validation).
|
|
89
|
+
CATEGORY_COUNTS = SEED_MAP.transform_values(&:size).freeze
|
|
90
|
+
|
|
91
|
+
# Expected item counts per category (for spec validation).
|
|
92
|
+
ITEM_COUNTS = SEED_MAP.each_with_object({}) do |(_, cats), hash|
|
|
93
|
+
cats.each { |cat_trait, items| hash[cat_trait] = items.size }
|
|
94
|
+
end.freeze
|
|
95
|
+
|
|
96
|
+
# Total counts across all business types.
|
|
97
|
+
TOTAL_BUSINESS_TYPES = SEED_MAP.size
|
|
98
|
+
TOTAL_CATEGORIES = SEED_MAP.values.sum(&:size)
|
|
99
|
+
TOTAL_ITEMS = SEED_MAP.values.flat_map(&:values).flatten.size
|
|
100
|
+
|
|
101
|
+
# Seed all (or one) business types with categories and items.
|
|
102
|
+
#
|
|
103
|
+
# @param business_type [Symbol, String, nil] Seed only this type, or all if nil.
|
|
104
|
+
# @return [Hash] Summary of created/found counts.
|
|
105
|
+
def self.seed!(business_type: nil)
|
|
106
|
+
new.seed!(business_type: business_type)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @param business_type [Symbol, String, nil]
|
|
110
|
+
# @return [Hash] Summary with :business_types, :categories, :items,
|
|
111
|
+
# :created, :found counts.
|
|
112
|
+
def seed!(business_type: nil)
|
|
113
|
+
types_to_seed = resolve_types(business_type)
|
|
114
|
+
|
|
115
|
+
counts = { business_types: 0, categories: 0, items: 0, created: 0, found: 0 }
|
|
116
|
+
|
|
117
|
+
ActiveRecord::Base.transaction do
|
|
118
|
+
types_to_seed.each do |bt_trait, categories_map|
|
|
119
|
+
bt, was_new = seed_business_type(bt_trait)
|
|
120
|
+
counts[:business_types] += 1
|
|
121
|
+
was_new ? counts[:created] += 1 : counts[:found] += 1
|
|
122
|
+
|
|
123
|
+
categories_map.each do |cat_trait, item_traits|
|
|
124
|
+
cat, was_new = seed_category(cat_trait, bt)
|
|
125
|
+
counts[:categories] += 1
|
|
126
|
+
was_new ? counts[:created] += 1 : counts[:found] += 1
|
|
127
|
+
|
|
128
|
+
item_traits.each do |item_trait|
|
|
129
|
+
_, was_new = seed_item(item_trait, cat)
|
|
130
|
+
counts[:items] += 1
|
|
131
|
+
was_new ? counts[:created] += 1 : counts[:found] += 1
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
SquareSandboxSimulator.logger.info(
|
|
138
|
+
"Seeding complete: #{counts[:business_types]} business types, " \
|
|
139
|
+
"#{counts[:categories]} categories, #{counts[:items]} items " \
|
|
140
|
+
"(#{counts[:created]} created, #{counts[:found]} found)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
counts
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
# Resolve which business types to seed.
|
|
149
|
+
#
|
|
150
|
+
# @param business_type [Symbol, String, nil]
|
|
151
|
+
# @return [Hash] subset of SEED_MAP
|
|
152
|
+
# @raise [ArgumentError] if the business type is unknown
|
|
153
|
+
def resolve_types(business_type)
|
|
154
|
+
return SEED_MAP if business_type.nil?
|
|
155
|
+
|
|
156
|
+
key = business_type.to_sym
|
|
157
|
+
unless SEED_MAP.key?(key)
|
|
158
|
+
raise ArgumentError,
|
|
159
|
+
"Unknown business type: #{key}. Valid types: #{SEED_MAP.keys.join(", ")}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
{ key => SEED_MAP[key] }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Find or create a business type using factory attributes.
|
|
166
|
+
#
|
|
167
|
+
# @param trait [Symbol]
|
|
168
|
+
# @return [Array(Models::BusinessType, Boolean)] record and whether it was newly created
|
|
169
|
+
def seed_business_type(trait)
|
|
170
|
+
attrs = FactoryBot.attributes_for(:business_type, trait)
|
|
171
|
+
record = Models::BusinessType.find_or_create_by!(key: attrs[:key]) do |bt|
|
|
172
|
+
bt.assign_attributes(attrs)
|
|
173
|
+
end
|
|
174
|
+
[record, record.previously_new_record?]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Traits that represent intentional duplicates -- always create
|
|
178
|
+
# a new record instead of reusing an existing one.
|
|
179
|
+
DUPLICATE_CATEGORY_TRAITS = %i[
|
|
180
|
+
coffee_espresso_dup pastries_dup breakfast_dup cafe_drinks cafe_grab_and_go
|
|
181
|
+
].freeze
|
|
182
|
+
|
|
183
|
+
DUPLICATE_ITEM_TRAITS = %i[
|
|
184
|
+
house_drip_coffee_dup latte_dup iced_latte matcha_latte
|
|
185
|
+
croissant_dup blueberry_muffin_dup almond_croissant banana_bread
|
|
186
|
+
avocado_toast_dup eggs_benedict french_toast
|
|
187
|
+
cafe_drip_coffee cafe_iced_tea cafe_lemonade cafe_hot_chocolate berry_blast_smoothie_dup
|
|
188
|
+
gg_croissant gg_muffin gg_blt gg_yogurt_parfait gg_cold_brew
|
|
189
|
+
].freeze
|
|
190
|
+
|
|
191
|
+
# Find or create a category using factory attributes.
|
|
192
|
+
# Duplicate traits always create a new record (mirroring real-world
|
|
193
|
+
# data where merchants have multiple categories with the same name).
|
|
194
|
+
#
|
|
195
|
+
# @param trait [Symbol]
|
|
196
|
+
# @param business_type [Models::BusinessType]
|
|
197
|
+
# @return [Array(Models::Category, Boolean)] record and whether it was newly created
|
|
198
|
+
def seed_category(trait, business_type)
|
|
199
|
+
attrs = FactoryBot.attributes_for(:category, trait)
|
|
200
|
+
|
|
201
|
+
if DUPLICATE_CATEGORY_TRAITS.include?(trait)
|
|
202
|
+
# Check by SKU-like hint (sort_order) to be idempotent even for dupes
|
|
203
|
+
existing = Models::Category.find_by(
|
|
204
|
+
name: attrs[:name], business_type: business_type, sort_order: attrs[:sort_order],
|
|
205
|
+
)
|
|
206
|
+
return [existing, false] if existing
|
|
207
|
+
|
|
208
|
+
record = Models::Category.create!(attrs.merge(business_type: business_type).except(:business_type_id))
|
|
209
|
+
[record, true]
|
|
210
|
+
else
|
|
211
|
+
record = Models::Category.find_or_create_by!(name: attrs[:name], business_type: business_type) do |cat|
|
|
212
|
+
cat.assign_attributes(attrs.except(:business_type_id))
|
|
213
|
+
end
|
|
214
|
+
[record, record.previously_new_record?]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Find or create an item using factory attributes.
|
|
219
|
+
# Duplicate traits always create a new record.
|
|
220
|
+
#
|
|
221
|
+
# @param trait [Symbol]
|
|
222
|
+
# @param category [Models::Category]
|
|
223
|
+
# @return [Array(Models::Item, Boolean)] record and whether it was newly created
|
|
224
|
+
def seed_item(trait, category)
|
|
225
|
+
attrs = FactoryBot.attributes_for(:item, trait)
|
|
226
|
+
|
|
227
|
+
if DUPLICATE_ITEM_TRAITS.include?(trait)
|
|
228
|
+
# Idempotent by SKU -- each duplicate has a unique SKU
|
|
229
|
+
existing = Models::Item.find_by(sku: attrs[:sku])
|
|
230
|
+
return [existing, false] if existing
|
|
231
|
+
|
|
232
|
+
record = Models::Item.create!(attrs.merge(category: category).except(:category_id))
|
|
233
|
+
[record, true]
|
|
234
|
+
else
|
|
235
|
+
record = Models::Item.find_or_create_by!(name: attrs[:name], category: category) do |item|
|
|
236
|
+
item.assign_attributes(attrs.except(:category_id))
|
|
237
|
+
end
|
|
238
|
+
[record, record.previously_new_record?]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|