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,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ FactoryBot.define do
6
+ factory :simulated_order, class: "SkytabSandboxSimulator::Models::SimulatedOrder" do
7
+ sequence(:skytab_location_id) { |n| "LOCATION#{n}" }
8
+ status { "open" }
9
+ business_date { Date.today }
10
+ subtotal { 0 }
11
+ tax_amount { 0 }
12
+ tip_amount { 0 }
13
+ discount_amount { 0 }
14
+ total { 0 }
15
+ metadata { {} }
16
+
17
+ trait :closed do
18
+ status { "closed" }
19
+ sequence(:skytab_ticket_id) { |n| "TKT#{n}#{SecureRandom.hex(4).upcase}" }
20
+ subtotal { 2500 } # cents
21
+ tax_amount { 206 } # cents (8.25%)
22
+ tip_amount { 450 } # cents
23
+ discount_amount { 0 } # cents
24
+ total { 3156 } # cents
25
+ meal_period { "lunch" }
26
+ revenue_class { "DINE_IN" }
27
+ end
28
+
29
+ trait :refunded do
30
+ status { "refunded" }
31
+ sequence(:skytab_ticket_id) { |n| "TKT#{n}#{SecureRandom.hex(4).upcase}" }
32
+ subtotal { 1800 }
33
+ tax_amount { 149 }
34
+ tip_amount { 0 }
35
+ discount_amount { 0 }
36
+ total { 1949 }
37
+ meal_period { "dinner" }
38
+ revenue_class { "DINE_IN" }
39
+ end
40
+
41
+ trait :voided do
42
+ status { "voided" }
43
+ subtotal { 0 }
44
+ tax_amount { 0 }
45
+ tip_amount { 0 }
46
+ total { 0 }
47
+ metadata { { "reason" => "Customer cancelled" } }
48
+ end
49
+
50
+ trait :with_business_type do
51
+ association :business_type, factory: [:business_type, :restaurant]
52
+ end
53
+
54
+ # Convenience: a closed order with associated payment
55
+ trait :with_payment do
56
+ closed
57
+
58
+ after(:create) do |order|
59
+ create(:simulated_payment, :completed, simulated_order: order, amount: order.total)
60
+ end
61
+ end
62
+
63
+ # Convenience: a closed order with a split payment
64
+ trait :with_split_payment do
65
+ closed
66
+ total { 4000 }
67
+ subtotal { 3400 }
68
+ tax_amount { 281 }
69
+ tip_amount { 319 }
70
+
71
+ after(:create) do |order|
72
+ half = order.total / 2
73
+ create(:simulated_payment, :completed, :cash_tender, simulated_order: order, amount: half)
74
+ create(:simulated_payment, :completed, :visa_tender, simulated_order: order, amount: order.total - half)
75
+ end
76
+ end
77
+
78
+ # Meal-period traits
79
+ trait :breakfast_order do
80
+ meal_period { "breakfast" }
81
+ revenue_class { "DINE_IN" }
82
+ end
83
+
84
+ trait :lunch_order do
85
+ meal_period { "lunch" }
86
+ revenue_class { "DINE_IN" }
87
+ end
88
+
89
+ trait :dinner_order do
90
+ meal_period { "dinner" }
91
+ revenue_class { "DINE_IN" }
92
+ end
93
+
94
+ trait :late_night_order do
95
+ meal_period { "late_night" }
96
+ revenue_class { "BAR" }
97
+ end
98
+
99
+ # Revenue class traits
100
+ trait :dine_in do
101
+ revenue_class { "DINE_IN" }
102
+ end
103
+
104
+ trait :takeout do
105
+ revenue_class { "TAKEOUT" }
106
+ end
107
+
108
+ trait :delivery do
109
+ revenue_class { "DELIVERY" }
110
+ end
111
+
112
+ trait :bar do
113
+ revenue_class { "BAR" }
114
+ end
115
+
116
+ trait :catering do
117
+ revenue_class { "CATERING" }
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ FactoryBot.define do
6
+ factory :simulated_payment, class: "SkytabSandboxSimulator::Models::SimulatedPayment" do
7
+ association :simulated_order
8
+ tender_name { "Cash" }
9
+ amount { 1500 } # cents
10
+ tip_amount { 0 } # cents
11
+ tax_amount { 0 } # cents
12
+ status { "pending" }
13
+ payment_type { nil }
14
+
15
+ # ── Status traits ──────────────────────────────────────────
16
+
17
+ trait :completed do
18
+ status { "completed" }
19
+ sequence(:skytab_payment_id) { |n| "PAY#{n}#{SecureRandom.hex(4).upcase}" }
20
+ end
21
+
22
+ trait :failed do
23
+ status { "failed" }
24
+ end
25
+
26
+ trait :refunded do
27
+ status { "refunded" }
28
+ sequence(:skytab_payment_id) { |n| "PAY#{n}#{SecureRandom.hex(4).upcase}" }
29
+ end
30
+
31
+ # ── Tender traits ──────────────────────────────────────────
32
+
33
+ trait :cash_tender do
34
+ tender_name { "CASH" }
35
+ payment_type { "cash" }
36
+ end
37
+
38
+ trait :visa_tender do
39
+ tender_name { "VISA" }
40
+ payment_type { "card" }
41
+ end
42
+
43
+ trait :mastercard_tender do
44
+ tender_name { "MASTERCARD" }
45
+ payment_type { "card" }
46
+ end
47
+
48
+ trait :amex_tender do
49
+ tender_name { "AMEX" }
50
+ payment_type { "card" }
51
+ end
52
+
53
+ trait :discover_tender do
54
+ tender_name { "DISCOVER" }
55
+ payment_type { "card" }
56
+ end
57
+
58
+ trait :gift_card_tender do
59
+ tender_name { "GIFT_CARD" }
60
+ payment_type { "gift_card" }
61
+ end
62
+
63
+ trait :house_account_tender do
64
+ tender_name { "HOUSE_ACCOUNT" }
65
+ payment_type { "house_account" }
66
+ end
67
+
68
+ # ── Split payment trait ────────────────────────────────────
69
+ trait :split do
70
+ status { "completed" }
71
+ sequence(:skytab_payment_id) { |n| "PAY#{n}#{SecureRandom.hex(4).upcase}" }
72
+ amount { 750 }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EnablePgcrypto < ActiveRecord::Migration[8.0]
4
+ def change
5
+ enable_extension "pgcrypto"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBusinessTypes < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :business_types, id: :uuid do |t|
6
+ t.string :key, null: false
7
+ t.string :name, null: false
8
+ t.text :description
9
+ t.string :industry
10
+ t.jsonb :order_profile, default: {}
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :business_types, :key, unique: true
16
+ add_index :business_types, :industry
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateCategories < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :categories, id: :uuid do |t|
6
+ t.references :business_type, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
7
+ t.string :name, null: false
8
+ t.integer :sort_order, default: 0
9
+ t.text :description
10
+ t.string :tax_group
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :categories, [:business_type_id, :name], unique: true
16
+ add_index :categories, :sort_order
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateItems < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :items, id: :uuid do |t|
6
+ t.references :category, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
7
+ t.string :name, null: false
8
+ t.integer :price, null: false # cents
9
+ t.string :sku
10
+ t.string :unit
11
+ t.boolean :active, default: true, null: false
12
+ t.jsonb :variants, default: []
13
+ t.jsonb :metadata, default: {}
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :items, [:category_id, :name], unique: true
19
+ add_index :items, :sku
20
+ add_index :items, :active
21
+ add_check_constraint :items, "price >= 0", name: "items_price_non_negative"
22
+ end
23
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSimulatedOrders < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :simulated_orders, id: :uuid do |t|
6
+ t.string :skytab_ticket_id
7
+ t.string :skytab_location_id, null: false
8
+ t.references :business_type, foreign_key: true, type: :uuid
9
+ t.string :status, null: false, default: "open"
10
+ t.integer :subtotal, default: 0 # cents
11
+ t.integer :tax_amount, default: 0 # cents
12
+ t.integer :tip_amount, default: 0 # cents
13
+ t.integer :discount_amount, default: 0 # cents
14
+ t.integer :total, default: 0 # cents
15
+ t.string :revenue_class
16
+ t.string :meal_period
17
+ t.jsonb :metadata, default: {}
18
+ t.date :business_date, null: false
19
+
20
+ t.timestamps
21
+ end
22
+
23
+ # Unique per location — prevents duplicate inserts on retry
24
+ add_index :simulated_orders, [:skytab_location_id, :skytab_ticket_id],
25
+ unique: true,
26
+ where: "skytab_ticket_id IS NOT NULL",
27
+ name: "idx_simulated_orders_location_ticket_unique"
28
+ add_index :simulated_orders, :skytab_location_id
29
+ add_index :simulated_orders, :status
30
+ add_index :simulated_orders, :business_date
31
+ add_index :simulated_orders, :meal_period
32
+ add_index :simulated_orders, :revenue_class
33
+ add_index :simulated_orders, :created_at
34
+ end
35
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSimulatedPayments < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :simulated_payments, id: :uuid do |t|
6
+ t.references :simulated_order, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
7
+ t.string :skytab_payment_id
8
+ t.string :tender_name, null: false
9
+ t.integer :amount, default: 0 # cents
10
+ t.integer :tip_amount, default: 0 # cents
11
+ t.integer :tax_amount, default: 0 # cents
12
+ t.string :status, null: false, default: "pending"
13
+ t.string :payment_type
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ # Unique — prevents duplicate payment inserts on retry
19
+ add_index :simulated_payments, :skytab_payment_id,
20
+ unique: true,
21
+ where: "skytab_payment_id IS NOT NULL"
22
+ add_index :simulated_payments, :tender_name
23
+ add_index :simulated_payments, :status
24
+ add_index :simulated_payments, :created_at
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateApiRequests < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :api_requests, id: :uuid do |t|
6
+ t.string :http_method, null: false
7
+ t.text :url, null: false
8
+ t.jsonb :request_payload, default: {}
9
+ t.jsonb :response_payload, default: {}
10
+ t.integer :response_status
11
+ t.integer :duration_ms
12
+ t.text :error_message
13
+ t.string :resource_type
14
+ t.string :resource_id
15
+ t.string :skytab_location_id
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :api_requests, :http_method
21
+ add_index :api_requests, :resource_id
22
+ add_index :api_requests, [:resource_type, :resource_id]
23
+ add_index :api_requests, :created_at
24
+ add_index :api_requests, :response_status
25
+ add_index :api_requests, :skytab_location_id
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateDailySummaries < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :daily_summaries, id: :uuid do |t|
6
+ t.string :location_id, null: false
7
+ t.date :business_date, null: false
8
+ t.integer :order_count, default: 0
9
+ t.integer :payment_count, default: 0
10
+ t.integer :refund_count, default: 0
11
+ t.integer :total_revenue, default: 0 # cents
12
+ t.integer :total_tax, default: 0 # cents
13
+ t.integer :total_tips, default: 0 # cents
14
+ t.integer :total_discounts, default: 0 # cents
15
+ t.jsonb :breakdown, default: {}
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :daily_summaries, [:location_id, :business_date], unique: true
21
+ add_index :daily_summaries, :business_date
22
+ add_index :daily_summaries, :created_at
23
+ end
24
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Generators
5
+ # Loads data from PostgreSQL when connected, falling back to JSON files.
6
+ #
7
+ # The DB path is preferred because it respects any runtime mutations
8
+ # (items deactivated, prices changed, categories added via Seeder).
9
+ # When no DB connection is available the loader reads the static JSON
10
+ # data files shipped with the gem.
11
+ class DataLoader
12
+ attr_reader :business_type
13
+
14
+ def initialize(business_type: :restaurant)
15
+ @business_type = business_type
16
+ end
17
+
18
+ # ── Primary data accessors (DB-first, JSON fallback) ───────
19
+
20
+ def categories
21
+ @categories ||= if db_connected?
22
+ categories_from_db
23
+ else
24
+ load_json("categories")["categories"]
25
+ end
26
+ end
27
+
28
+ def items
29
+ @items ||= if db_connected?
30
+ items_from_db
31
+ else
32
+ load_json("items")["items"]
33
+ end
34
+ end
35
+
36
+ # ── JSON-only accessors (no DB models for these) ───────────
37
+
38
+ def tenders
39
+ @tenders ||= load_json("tenders")["tenders"]
40
+ end
41
+
42
+ def revenue_classes
43
+ @revenue_classes ||= load_json("tenders")["revenue_classes"]
44
+ rescue Error
45
+ %w[DINE_IN TAKEOUT DELIVERY BAR CATERING]
46
+ end
47
+
48
+ def tax_rates
49
+ @tax_rates ||= load_json("tenders")["tax_rates"]
50
+ rescue Error
51
+ []
52
+ end
53
+
54
+ # ── Convenience filters ────────────────────────────────────
55
+
56
+ def items_for_category(category_name)
57
+ items.select { |item| item["category"] == category_name }
58
+ end
59
+
60
+ # ── Data source introspection ──────────────────────────────
61
+
62
+ def data_source
63
+ db_connected? ? :db : :json
64
+ end
65
+
66
+ private
67
+
68
+ def db_connected?
69
+ Database.connected?
70
+ end
71
+
72
+ # ── DB query methods ───────────────────────────────────────
73
+
74
+ def categories_from_db
75
+ bt = Models::BusinessType.find_by(key: business_type.to_s)
76
+ return load_json("categories")["categories"] unless bt
77
+
78
+ bt.categories.sorted.map do |cat|
79
+ {
80
+ "name" => cat.name,
81
+ "sort_order" => cat.sort_order,
82
+ "description" => cat.description
83
+ }
84
+ end
85
+ end
86
+
87
+ def items_from_db
88
+ bt = Models::BusinessType.find_by(key: business_type.to_s)
89
+ return load_json("items")["items"] unless bt
90
+
91
+ Models::Item.active
92
+ .for_business_type(business_type.to_s)
93
+ .includes(:category)
94
+ .order("categories.sort_order", "items.name")
95
+ .map do |item|
96
+ hash = {
97
+ "name" => item.name,
98
+ "price" => item.price,
99
+ "category" => item.category.name
100
+ }
101
+
102
+ hash["sku"] = item.sku if item.sku.present?
103
+ hash["unit"] = item.unit if item.unit.present?
104
+ hash
105
+ end
106
+ end
107
+
108
+ # ── JSON loading ───────────────────────────────────────────
109
+
110
+ def load_json(filename)
111
+ path = File.join(data_path, "#{filename}.json")
112
+
113
+ unless File.exist?(path)
114
+ raise Error, "Data file not found: #{path}"
115
+ end
116
+
117
+ JSON.parse(File.read(path))
118
+ end
119
+
120
+ def data_path
121
+ File.join(SkytabSandboxSimulator.root, "lib", "skytab_sandbox_simulator", "data", business_type.to_s)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SkytabSandboxSimulator
4
+ module Generators
5
+ # Creates restaurant entities in SkyTab (categories, items, tenders, etc.)
6
+ class EntityGenerator
7
+ LOG_SEPARATOR = ("=" * 60).freeze
8
+
9
+ attr_reader :services, :data, :logger
10
+
11
+ def initialize(services: nil, business_type: :restaurant)
12
+ @services = services || Services::Skytab::ServiceManager.new
13
+ @data = DataLoader.new(business_type: business_type)
14
+ @logger = SkytabSandboxSimulator.logger
15
+ end
16
+
17
+ # Set up all entities (categories, items)
18
+ def setup_all
19
+ logger.info LOG_SEPARATOR
20
+ logger.info "Setting up entities in SkyTab..."
21
+ logger.info LOG_SEPARATOR
22
+
23
+ results = {
24
+ categories: setup_categories,
25
+ items: setup_items
26
+ }
27
+
28
+ logger.info LOG_SEPARATOR
29
+ logger.info "Entity setup complete!"
30
+ logger.info " Categories: #{results[:categories].size}"
31
+ logger.info " Items: #{results[:items].size}"
32
+ logger.info LOG_SEPARATOR
33
+
34
+ results
35
+ end
36
+
37
+ # Create categories from data file
38
+ def setup_categories
39
+ logger.info "Setting up categories..."
40
+
41
+ existing = services.catalog.get_categories
42
+ existing_names = existing.map { |c| c["name"] }
43
+
44
+ created = []
45
+ data.categories.each do |cat|
46
+ if existing_names.include?(cat["name"])
47
+ logger.debug "Category already exists: #{cat['name']}"
48
+ next
49
+ end
50
+
51
+ result = services.catalog.create_category(
52
+ name: cat["name"],
53
+ sort_order: cat["sort_order"]
54
+ )
55
+ created << result if result
56
+ rescue StandardError => e
57
+ logger.warn "Failed to create category #{cat['name']}: #{e.message}"
58
+ end
59
+
60
+ logger.info "Created #{created.size} categories (#{existing.size} already existed)"
61
+ existing + created
62
+ end
63
+
64
+ # Create items from data file
65
+ def setup_items
66
+ logger.info "Setting up items..."
67
+
68
+ existing_items = services.catalog.get_items
69
+ existing_names = existing_items.map { |i| i["name"] }
70
+
71
+ # Build category name -> ID mapping
72
+ categories = services.catalog.get_categories
73
+ cat_map = categories.each_with_object({}) { |c, h| h[c["name"]] = c["id"] }
74
+
75
+ created = []
76
+ data.items.each do |item|
77
+ if existing_names.include?(item["name"])
78
+ logger.debug "Item already exists: #{item['name']}"
79
+ next
80
+ end
81
+
82
+ category_id = cat_map[item["category"]]
83
+
84
+ result = services.catalog.create_item(
85
+ name: item["name"],
86
+ price: item["price"],
87
+ category_id: category_id,
88
+ sku: item["sku"]
89
+ )
90
+ created << result if result
91
+ rescue StandardError => e
92
+ logger.warn "Failed to create item #{item['name']}: #{e.message}"
93
+ end
94
+
95
+ logger.info "Created #{created.size} items (#{existing_items.size} already existed)"
96
+ existing_items + created
97
+ end
98
+
99
+ # Delete all entities
100
+ def delete_all
101
+ logger.warn "Deleting all entities..."
102
+ services.catalog.delete_all
103
+ logger.info "All entities deleted"
104
+ end
105
+ end
106
+ end
107
+ end