epos_now_sandbox_simulator 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +10 -0
- data/LICENSE +21 -0
- data/README.md +380 -0
- data/bin/simulate +309 -0
- data/lib/epos_now_sandbox_simulator/configuration.rb +173 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/items.json +26 -0
- data/lib/epos_now_sandbox_simulator/data/bar_nightclub/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/items.json +28 -0
- data/lib/epos_now_sandbox_simulator/data/cafe_bakery/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/items.json +29 -0
- data/lib/epos_now_sandbox_simulator/data/restaurant/tenders.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/categories.json +9 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/items.json +17 -0
- data/lib/epos_now_sandbox_simulator/data/retail_general/tenders.json +8 -0
- data/lib/epos_now_sandbox_simulator/database.rb +136 -0
- data/lib/epos_now_sandbox_simulator/db/factories/api_requests.rb +13 -0
- data/lib/epos_now_sandbox_simulator/db/factories/business_types.rb +34 -0
- data/lib/epos_now_sandbox_simulator/db/factories/categories.rb +10 -0
- data/lib/epos_now_sandbox_simulator/db/factories/items.rb +12 -0
- data/lib/epos_now_sandbox_simulator/db/factories/simulated_orders.rb +25 -0
- data/lib/epos_now_sandbox_simulator/db/factories/simulated_payments.rb +14 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000001_enable_pgcrypto.rb +7 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000002_create_business_types.rb +16 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000003_create_categories.rb +16 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000004_create_items.rb +19 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000005_create_simulated_orders.rb +27 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000006_create_simulated_payments.rb +22 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000007_create_api_requests.rb +22 -0
- data/lib/epos_now_sandbox_simulator/db/migrate/20260312000008_create_daily_summaries.rb +21 -0
- data/lib/epos_now_sandbox_simulator/generators/data_loader.rb +100 -0
- data/lib/epos_now_sandbox_simulator/generators/entity_generator.rb +103 -0
- data/lib/epos_now_sandbox_simulator/generators/order_generator.rb +336 -0
- data/lib/epos_now_sandbox_simulator/models/api_request.rb +16 -0
- data/lib/epos_now_sandbox_simulator/models/business_type.rb +16 -0
- data/lib/epos_now_sandbox_simulator/models/category.rb +18 -0
- data/lib/epos_now_sandbox_simulator/models/daily_summary.rb +43 -0
- data/lib/epos_now_sandbox_simulator/models/item.rb +20 -0
- data/lib/epos_now_sandbox_simulator/models/simulated_order.rb +21 -0
- data/lib/epos_now_sandbox_simulator/models/simulated_payment.rb +17 -0
- data/lib/epos_now_sandbox_simulator/seeder.rb +119 -0
- data/lib/epos_now_sandbox_simulator/services/base_service.rb +248 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/inventory_service.rb +178 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/services_manager.rb +56 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/tax_service.rb +45 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/tender_service.rb +90 -0
- data/lib/epos_now_sandbox_simulator/services/epos_now/transaction_service.rb +171 -0
- data/lib/epos_now_sandbox_simulator.rb +49 -0
- metadata +334 -0
|
@@ -0,0 +1,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,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,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
|