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.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +380 -0
  5. data/bin/simulate +309 -0
  6. data/lib/epos_now_sandbox_simulator/configuration.rb +173 -0
  7. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
  8. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/items.json +26 -0
  9. data/lib/epos_now_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
  10. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
  11. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/items.json +28 -0
  12. data/lib/epos_now_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
  13. data/lib/epos_now_sandbox_simulator/data/restaurant/categories.json +9 -0
  14. data/lib/epos_now_sandbox_simulator/data/restaurant/items.json +29 -0
  15. data/lib/epos_now_sandbox_simulator/data/restaurant/tenders.json +9 -0
  16. data/lib/epos_now_sandbox_simulator/data/retail_general/categories.json +9 -0
  17. data/lib/epos_now_sandbox_simulator/data/retail_general/items.json +17 -0
  18. data/lib/epos_now_sandbox_simulator/data/retail_general/tenders.json +8 -0
  19. data/lib/epos_now_sandbox_simulator/database.rb +136 -0
  20. data/lib/epos_now_sandbox_simulator/db/factories/api_requests.rb +13 -0
  21. data/lib/epos_now_sandbox_simulator/db/factories/business_types.rb +34 -0
  22. data/lib/epos_now_sandbox_simulator/db/factories/categories.rb +10 -0
  23. data/lib/epos_now_sandbox_simulator/db/factories/items.rb +12 -0
  24. data/lib/epos_now_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
  25. data/lib/epos_now_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
  26. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000001_enable_pgcrypto.rb +7 -0
  27. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000002_create_business_types.rb +16 -0
  28. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000003_create_categories.rb +16 -0
  29. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000004_create_items.rb +19 -0
  30. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000005_create_simulated_orders.rb +27 -0
  31. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000006_create_simulated_payments.rb +22 -0
  32. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000007_create_api_requests.rb +22 -0
  33. data/lib/epos_now_sandbox_simulator/db/migrate/20260312000008_create_daily_summaries.rb +21 -0
  34. data/lib/epos_now_sandbox_simulator/generators/data_loader.rb +100 -0
  35. data/lib/epos_now_sandbox_simulator/generators/entity_generator.rb +103 -0
  36. data/lib/epos_now_sandbox_simulator/generators/order_generator.rb +336 -0
  37. data/lib/epos_now_sandbox_simulator/models/api_request.rb +16 -0
  38. data/lib/epos_now_sandbox_simulator/models/business_type.rb +16 -0
  39. data/lib/epos_now_sandbox_simulator/models/category.rb +18 -0
  40. data/lib/epos_now_sandbox_simulator/models/daily_summary.rb +43 -0
  41. data/lib/epos_now_sandbox_simulator/models/item.rb +20 -0
  42. data/lib/epos_now_sandbox_simulator/models/simulated_order.rb +21 -0
  43. data/lib/epos_now_sandbox_simulator/models/simulated_payment.rb +17 -0
  44. data/lib/epos_now_sandbox_simulator/seeder.rb +119 -0
  45. data/lib/epos_now_sandbox_simulator/services/base_service.rb +248 -0
  46. data/lib/epos_now_sandbox_simulator/services/epos_now/inventory_service.rb +178 -0
  47. data/lib/epos_now_sandbox_simulator/services/epos_now/services_manager.rb +56 -0
  48. data/lib/epos_now_sandbox_simulator/services/epos_now/tax_service.rb +45 -0
  49. data/lib/epos_now_sandbox_simulator/services/epos_now/tender_service.rb +90 -0
  50. data/lib/epos_now_sandbox_simulator/services/epos_now/transaction_service.rb +171 -0
  51. data/lib/epos_now_sandbox_simulator.rb +49 -0
  52. metadata +334 -0
@@ -0,0 +1,17 @@
1
+ {
2
+ "items": [
3
+ { "name": "Phone Charger", "price": 14.99, "category": "Electronics", "sku": "RET-ELC-001" },
4
+ { "name": "Bluetooth Speaker", "price": 29.99, "category": "Electronics", "sku": "RET-ELC-002" },
5
+ { "name": "USB Cable", "price": 9.99, "category": "Electronics", "sku": "RET-ELC-003" },
6
+ { "name": "T-Shirt", "price": 19.99, "category": "Clothing", "sku": "RET-CLO-001" },
7
+ { "name": "Baseball Cap", "price": 14.99, "category": "Clothing", "sku": "RET-CLO-002" },
8
+ { "name": "Socks Pack", "price": 8.99, "category": "Clothing", "sku": "RET-CLO-003" },
9
+ { "name": "Candle", "price": 12.99, "category": "Home & Garden", "sku": "RET-HOM-001" },
10
+ { "name": "Plant Pot", "price": 9.99, "category": "Home & Garden", "sku": "RET-HOM-002" },
11
+ { "name": "Picture Frame", "price": 11.99, "category": "Home & Garden", "sku": "RET-HOM-003" },
12
+ { "name": "Hand Cream", "price": 7.99, "category": "Health & Beauty", "sku": "RET-HLT-001" },
13
+ { "name": "Lip Balm", "price": 4.99, "category": "Health & Beauty", "sku": "RET-HLT-002" },
14
+ { "name": "Snack Bar", "price": 2.99, "category": "Groceries", "sku": "RET-GRC-001" },
15
+ { "name": "Water Bottle", "price": 1.99, "category": "Groceries", "sku": "RET-GRC-002" }
16
+ ]
17
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tenders": [
3
+ { "name": "Cash", "description": "Cash payment", "weight": 20 },
4
+ { "name": "Credit Card", "description": "Credit card payment", "weight": 50 },
5
+ { "name": "Debit Card", "description": "Debit card payment", "weight": 25 },
6
+ { "name": "Gift Card", "description": "Gift card payment", "weight": 5 }
7
+ ]
8
+ }
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "logger"
5
+
6
+ module EposNowSandboxSimulator
7
+ # Standalone ActiveRecord connection manager for PostgreSQL.
8
+ #
9
+ # Provides database connectivity without requiring Rails.
10
+ # Used for persisting Epos Now sandbox data (orders, payments, etc.)
11
+ # alongside the existing JSON-file and API-based workflows.
12
+ module Database
13
+ MIGRATIONS_PATH = File.expand_path("db/migrate", __dir__).freeze
14
+ TEST_DATABASE = "epos_now_simulator_test"
15
+
16
+ class << self
17
+ def create!(url)
18
+ db_name = URI.parse(url).path.delete_prefix("/")
19
+ maintenance_url = url.sub(%r{/[^/]+\z}, "/postgres")
20
+
21
+ ActiveRecord::Base.establish_connection(maintenance_url)
22
+ ActiveRecord::Base.connection.create_database(db_name)
23
+ EposNowSandboxSimulator.logger.info("Database created: #{db_name}")
24
+ rescue ActiveRecord::DatabaseAlreadyExists, ActiveRecord::StatementInvalid => e
25
+ raise unless e.message.include?("already exists")
26
+
27
+ EposNowSandboxSimulator.logger.info("Database already exists: #{db_name}")
28
+ ensure
29
+ ActiveRecord::Base.connection_pool.disconnect!
30
+ end
31
+
32
+ def drop!(url)
33
+ db_name = URI.parse(url).path.delete_prefix("/")
34
+ maintenance_url = url.sub(%r{/[^/]+\z}, "/postgres")
35
+
36
+ ActiveRecord::Base.establish_connection(maintenance_url)
37
+ ActiveRecord::Base.connection.drop_database(db_name)
38
+ EposNowSandboxSimulator.logger.info("Database dropped: #{db_name}")
39
+ rescue ActiveRecord::StatementInvalid => e
40
+ raise unless e.message.include?("does not exist")
41
+
42
+ EposNowSandboxSimulator.logger.info("Database does not exist: #{db_name}")
43
+ ensure
44
+ ActiveRecord::Base.connection_pool.disconnect!
45
+ end
46
+
47
+ def database_url
48
+ url = Configuration.database_url_from_file
49
+ raise Error, "No DATABASE_URL found in .env.json" unless url
50
+
51
+ url
52
+ end
53
+
54
+ def connect!(url)
55
+ raise ArgumentError, "Expected a PostgreSQL URL, got: #{url.split("://").first}://" unless url.match?(%r{\Apostgres(ql)?://}i)
56
+
57
+ ActiveRecord::Base.establish_connection(url)
58
+ ActiveRecord::Base.connection.execute("SELECT 1")
59
+ ActiveRecord::Base.logger = EposNowSandboxSimulator.logger
60
+
61
+ EposNowSandboxSimulator.logger.info("Database connected: #{sanitize_url(url)}")
62
+ end
63
+
64
+ def migrate!
65
+ ensure_connected!
66
+
67
+ EposNowSandboxSimulator.logger.info("Running migrations from #{MIGRATIONS_PATH}")
68
+ context = ActiveRecord::MigrationContext.new(MIGRATIONS_PATH)
69
+ context.migrate
70
+ EposNowSandboxSimulator.logger.info("Migrations complete")
71
+ end
72
+
73
+ def seed!(business_type: nil)
74
+ ensure_connected!
75
+ load_factories!
76
+
77
+ business_type ||= EposNowSandboxSimulator.configuration.business_type
78
+ Seeder.seed!(business_type: business_type)
79
+ end
80
+
81
+ def connected?
82
+ ActiveRecord::Base.connection_pool.with_connection(&:active?)
83
+ rescue StandardError
84
+ false
85
+ end
86
+
87
+ def disconnect!
88
+ ActiveRecord::Base.connection_pool.disconnect!
89
+ EposNowSandboxSimulator.logger.info("Database disconnected")
90
+ end
91
+
92
+ def test_database_url(base_url: nil)
93
+ url = base_url || Configuration.database_url_from_file
94
+ return "postgres://localhost:5432/#{TEST_DATABASE}" if url.nil?
95
+
96
+ uri = URI.parse(url)
97
+ uri.path = "/#{TEST_DATABASE}"
98
+ uri.to_s
99
+ rescue URI::InvalidURIError
100
+ "postgres://localhost:5432/#{TEST_DATABASE}"
101
+ end
102
+
103
+ private
104
+
105
+ def ensure_connected!
106
+ return if connected?
107
+
108
+ raise EposNowSandboxSimulator::Error,
109
+ "Database not connected. Call Database.connect!(url) first."
110
+ end
111
+
112
+ def load_factories!
113
+ return if @factories_loaded
114
+
115
+ require "factory_bot"
116
+
117
+ factories_path = File.expand_path("db/factories", __dir__)
118
+ FactoryBot.definition_file_paths = [factories_path] if Dir.exist?(factories_path)
119
+ FactoryBot.find_definitions
120
+ @factories_loaded = true
121
+ rescue StandardError => e
122
+ EposNowSandboxSimulator.logger.warn("Could not load factories: #{e.message}")
123
+ end
124
+
125
+ def sanitize_url(url)
126
+ uri = URI.parse(url)
127
+ has_password = !uri.password.nil?
128
+ uri.user = "***" if uri.user
129
+ uri.password = "***" if has_password
130
+ uri.to_s
131
+ rescue URI::InvalidURIError
132
+ url.gsub(%r{://[^@]+@}, "://***:***@")
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :api_request, class: "EposNowSandboxSimulator::Models::ApiRequest" do
5
+ http_method { "GET" }
6
+ url { "https://api.eposnowhq.com/api/V2/Category" }
7
+ request_payload { {} }
8
+ response_status { 200 }
9
+ response_payload { {} }
10
+ duration_ms { rand(50..500) }
11
+ resource_type { "Category" }
12
+ end
13
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :business_type, class: "EposNowSandboxSimulator::Models::BusinessType" do
5
+ sequence(:key) { |n| "business_type_#{n}" }
6
+ name { key.tr("_", " ").split.map(&:capitalize).join(" ") }
7
+ industry { "Food" }
8
+ order_profile { {} }
9
+
10
+ trait :restaurant do
11
+ key { "restaurant" }
12
+ name { "Restaurant" }
13
+ industry { "Food" }
14
+ end
15
+
16
+ trait :cafe_bakery do
17
+ key { "cafe_bakery" }
18
+ name { "Cafe Bakery" }
19
+ industry { "Food" }
20
+ end
21
+
22
+ trait :bar_nightclub do
23
+ key { "bar_nightclub" }
24
+ name { "Bar Nightclub" }
25
+ industry { "Food" }
26
+ end
27
+
28
+ trait :retail_general do
29
+ key { "retail_general" }
30
+ name { "Retail General" }
31
+ industry { "Retail" }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :category, class: "EposNowSandboxSimulator::Models::Category" do
5
+ business_type
6
+ sequence(:name) { |n| "Category #{n}" }
7
+ sort_order { 1 }
8
+ description { "A test category" }
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :item, class: "EposNowSandboxSimulator::Models::Item" do
5
+ business_type
6
+ category
7
+ sequence(:name) { |n| "Item #{n}" }
8
+ price { rand(299..2999) } # cents
9
+ sequence(:sku) { |n| "SKU-#{n.to_s.rjust(5, "0")}" }
10
+ metadata { {} }
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :simulated_order, class: "EposNowSandboxSimulator::Models::SimulatedOrder" do
5
+ sequence(:epos_now_transaction_id) { |n| n + 10_000 }
6
+ status { "paid" }
7
+ business_date { Date.today }
8
+ dining_option { %w[walk_in take_away delivery].sample }
9
+ meal_period { %w[breakfast lunch happy_hour dinner late_night].sample }
10
+ subtotal { rand(1000..5000) }
11
+ tax_amount { (subtotal * 0.0825).round }
12
+ tip_amount { rand(0..500) }
13
+ discount_amount { 0 }
14
+ total { subtotal + tax_amount + tip_amount - discount_amount }
15
+ metadata { {} }
16
+
17
+ trait :refunded do
18
+ status { "refunded" }
19
+ end
20
+
21
+ trait :open do
22
+ status { "open" }
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ FactoryBot.define do
4
+ factory :simulated_payment, class: "EposNowSandboxSimulator::Models::SimulatedPayment" do
5
+ simulated_order
6
+ sequence(:epos_now_tender_id) { |n| n + 20_000 }
7
+ tender_name { "Credit Card" }
8
+ amount { simulated_order&.total || rand(1000..5000) }
9
+ tip_amount { 0 }
10
+ tax_amount { 0 }
11
+ status { "success" }
12
+ payment_type { "credit_card" }
13
+ end
14
+ 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,16 @@
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.string :industry, null: false
9
+ t.jsonb :order_profile, default: {}
10
+
11
+ t.timestamps
12
+
13
+ t.index :key, unique: true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
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.string :name, null: false
7
+ t.string :description
8
+ t.integer :sort_order, null: false, default: 0
9
+ t.references :business_type, type: :uuid, null: false, foreign_key: true
10
+
11
+ t.timestamps
12
+
13
+ t.index %i[business_type_id name], unique: true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,19 @@
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.string :name, null: false
7
+ t.integer :price, null: false # price in cents
8
+ t.string :sku
9
+ t.string :barcode
10
+ t.jsonb :metadata, default: {}
11
+ t.references :business_type, type: :uuid, null: false, foreign_key: true
12
+ t.references :category, type: :uuid, foreign_key: true
13
+
14
+ t.timestamps
15
+
16
+ t.index :sku, unique: true
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,27 @@
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.integer :epos_now_transaction_id
7
+ t.string :status, null: false, default: "open"
8
+ t.date :business_date
9
+ t.string :dining_option # walk_in, take_away, delivery
10
+ t.string :meal_period # breakfast, lunch, happy_hour, dinner, late_night
11
+ t.integer :subtotal, default: 0 # cents
12
+ t.integer :tax_amount, default: 0 # cents
13
+ t.integer :tip_amount, default: 0 # cents
14
+ t.integer :discount_amount, default: 0 # cents
15
+ t.integer :total, default: 0 # cents
16
+ t.jsonb :metadata, default: {}
17
+
18
+ t.timestamps
19
+
20
+ t.index :epos_now_transaction_id, unique: true
21
+ t.index :status
22
+ t.index :business_date
23
+ t.index :meal_period
24
+ t.index :dining_option
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
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, type: :uuid, null: false, foreign_key: { on_delete: :cascade }
7
+ t.integer :epos_now_tender_id
8
+ t.string :tender_name, null: false
9
+ t.integer :amount, null: false, 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
+
17
+ t.index :epos_now_tender_id, unique: true
18
+ t.index :tender_name
19
+ t.index :status
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,22 @@
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.string :url, null: false
8
+ t.jsonb :request_payload, default: {}
9
+ t.integer :response_status
10
+ t.jsonb :response_payload, default: {}
11
+ t.integer :duration_ms
12
+ t.string :error_message
13
+ t.string :resource_type
14
+ t.string :resource_id
15
+
16
+ t.timestamps
17
+
18
+ t.index :resource_type
19
+ t.index :created_at
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,21 @@
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.date :summary_date, null: false
7
+ t.integer :order_count, default: 0
8
+ t.integer :payment_count, default: 0
9
+ t.integer :refund_count, default: 0
10
+ t.integer :total_revenue, default: 0 # cents
11
+ t.integer :total_tax, default: 0 # cents
12
+ t.integer :total_tips, default: 0 # cents
13
+ t.integer :total_discounts, default: 0 # cents
14
+ t.jsonb :breakdown, default: {}
15
+
16
+ t.timestamps
17
+
18
+ t.index :summary_date, unique: true
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Generators
5
+ # Loads business data from DB (preferred) or JSON files (fallback).
6
+ #
7
+ # Returns identically-shaped hashes regardless of source.
8
+ class DataLoader
9
+ DATA_DIR = File.expand_path("../data", __dir__).freeze
10
+
11
+ BUSINESS_TYPES = %i[restaurant cafe_bakery bar_nightclub retail_general].freeze
12
+
13
+ attr_reader :business_type
14
+
15
+ def initialize(business_type: :restaurant)
16
+ @business_type = business_type.to_sym
17
+ end
18
+
19
+ # Load categories for the business type
20
+ # @return [Array<Hash>] Categories with :name, :sort_order, :description
21
+ def load_categories
22
+ if Database.connected?
23
+ load_categories_from_db
24
+ else
25
+ load_categories_from_json
26
+ end
27
+ end
28
+
29
+ # Load items for the business type
30
+ # @return [Array<Hash>] Items with :name, :price, :category, :sku
31
+ def load_items
32
+ if Database.connected?
33
+ load_items_from_db
34
+ else
35
+ load_items_from_json
36
+ end
37
+ end
38
+
39
+ # Load tender types
40
+ # @return [Array<Hash>] Tenders with :name, :description, :weight
41
+ def load_tenders
42
+ load_from_json("tenders")["tenders"] || []
43
+ end
44
+
45
+ # Load items grouped by category
46
+ # @return [Hash<String, Array<Hash>>] Category name => items
47
+ def load_items_by_category
48
+ items = load_items
49
+ items.group_by { |i| i["category"] || i[:category] }
50
+ end
51
+
52
+ private
53
+
54
+ def load_categories_from_db
55
+ bt = Models::BusinessType.find_by(key: business_type.to_s)
56
+ return load_categories_from_json unless bt
57
+
58
+ bt.categories.order(:sort_order).map do |cat|
59
+ { "name" => cat.name, "sort_order" => cat.sort_order, "description" => cat.description }
60
+ end
61
+ end
62
+
63
+ def load_items_from_db
64
+ bt = Models::BusinessType.find_by(key: business_type.to_s)
65
+ return load_items_from_json unless bt
66
+
67
+ bt.items.includes(:category).map do |item|
68
+ {
69
+ "name" => item.name,
70
+ "price" => item.price / 100.0, # stored in cents
71
+ "category" => item.category&.name,
72
+ "sku" => item.sku
73
+ }
74
+ end
75
+ end
76
+
77
+ def load_categories_from_json
78
+ load_from_json("categories")["categories"] || []
79
+ end
80
+
81
+ def load_items_from_json
82
+ load_from_json("items")["items"] || []
83
+ end
84
+
85
+ def load_from_json(filename)
86
+ path = File.join(DATA_DIR, business_type.to_s, "#{filename}.json")
87
+
88
+ unless File.exist?(path)
89
+ EposNowSandboxSimulator.logger.warn "Data file not found: #{path}"
90
+ return {}
91
+ end
92
+
93
+ JSON.parse(File.read(path))
94
+ rescue JSON::ParserError => e
95
+ EposNowSandboxSimulator.logger.error "Failed to parse #{path}: #{e.message}"
96
+ {}
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EposNowSandboxSimulator
4
+ module Generators
5
+ # Sets up all POS entities in Epos Now: categories, products, tender types.
6
+ #
7
+ # All operations are idempotent — safe to run multiple times.
8
+ #
9
+ # @example
10
+ # generator = EntityGenerator.new(business_type: :restaurant)
11
+ # generator.setup_all
12
+ class EntityGenerator
13
+ attr_reader :business_type, :services, :data_loader
14
+
15
+ def initialize(business_type: :restaurant, config: nil)
16
+ @business_type = business_type.to_sym
17
+ @config = config || EposNowSandboxSimulator.configuration
18
+ @services = Services::EposNow::ServicesManager.new(config: @config)
19
+ @data_loader = DataLoader.new(business_type: @business_type)
20
+ end
21
+
22
+ # Set up everything: categories, products, tender types
23
+ def setup_all
24
+ logger.info "Setting up #{business_type} entities..."
25
+
26
+ tender_types = setup_tender_types
27
+ categories = setup_categories
28
+ products = setup_products(categories)
29
+
30
+ stats = {
31
+ tender_types: tender_types.size,
32
+ categories: categories.size,
33
+ products: products.size
34
+ }
35
+
36
+ logger.info "Setup complete: #{stats}"
37
+ stats
38
+ end
39
+
40
+ # Create tender types from JSON data
41
+ # @return [Array<Hash>] Created/existing tender types
42
+ def setup_tender_types
43
+ tenders_data = data_loader.load_tenders
44
+ logger.info "Setting up #{tenders_data.size} tender types..."
45
+
46
+ tenders_data.map do |td|
47
+ services.tender.find_or_create_tender_type(
48
+ name: td["name"],
49
+ description: td["description"]
50
+ )
51
+ end
52
+ end
53
+
54
+ # Create categories from JSON data
55
+ # @return [Hash<String, Hash>] Category name => Epos Now category response
56
+ def setup_categories
57
+ categories_data = data_loader.load_categories
58
+ logger.info "Setting up #{categories_data.size} categories..."
59
+
60
+ result = {}
61
+ categories_data.each do |cat|
62
+ epos_cat = services.inventory.find_or_create_category(
63
+ name: cat["name"],
64
+ description: cat["description"],
65
+ show_on_till: true,
66
+ sort_position: cat["sort_order"]
67
+ )
68
+ result[cat["name"]] = epos_cat
69
+ end
70
+
71
+ result
72
+ end
73
+
74
+ # Create products from JSON data, linked to categories
75
+ # @param categories [Hash<String, Hash>] Category name => Epos Now category
76
+ # @return [Array<Hash>] Created/existing products
77
+ def setup_products(categories = nil)
78
+ categories ||= setup_categories
79
+ items_data = data_loader.load_items
80
+ logger.info "Setting up #{items_data.size} products..."
81
+
82
+ items_data.map do |item|
83
+ category = categories[item["category"]]
84
+ category_id = category&.dig("Id")
85
+
86
+ services.inventory.find_or_create_product(
87
+ name: item["name"],
88
+ sale_price: item["price"],
89
+ cost_price: (item["price"] * 0.35).round(2), # ~35% cost
90
+ category_id: category_id,
91
+ barcode: item["sku"]
92
+ )
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def logger
99
+ EposNowSandboxSimulator.logger
100
+ end
101
+ end
102
+ end
103
+ end