cuzk-soap 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/README.md +311 -0
- data/lib/cuzk/soap/authentication/test_credentials.rb +32 -0
- data/lib/cuzk/soap/authentication/wsdp_credentials.rb +57 -0
- data/lib/cuzk/soap/batch_processor.rb +112 -0
- data/lib/cuzk/soap/change_tracker.rb +110 -0
- data/lib/cuzk/soap/client.rb +196 -0
- data/lib/cuzk/soap/configuration.rb +117 -0
- data/lib/cuzk/soap/connection.rb +138 -0
- data/lib/cuzk/soap/cost_manager.rb +136 -0
- data/lib/cuzk/soap/document_manager.rb +106 -0
- data/lib/cuzk/soap/errors.rb +47 -0
- data/lib/cuzk/soap/property_report.rb +90 -0
- data/lib/cuzk/soap/services/account_service.rb +30 -0
- data/lib/cuzk/soap/services/base_service.rb +86 -0
- data/lib/cuzk/soap/services/ciselnik_service.rb +20 -0
- data/lib/cuzk/soap/services/informace_service.rb +20 -0
- data/lib/cuzk/soap/services/report_service.rb +113 -0
- data/lib/cuzk/soap/services/search_service.rb +87 -0
- data/lib/cuzk/soap/version.rb +7 -0
- data/lib/cuzk/soap/wsdl_manager.rb +171 -0
- data/lib/cuzk/soap.rb +39 -0
- metadata +250 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CUZK
|
|
4
|
+
module SOAP
|
|
5
|
+
class Client
|
|
6
|
+
attr_reader :configuration, :connection, :cost_manager
|
|
7
|
+
|
|
8
|
+
def initialize(username: nil, password: nil, environment: nil, **options)
|
|
9
|
+
@configuration = CUZK::SOAP.configuration.dup
|
|
10
|
+
@configuration.username = username if username
|
|
11
|
+
@configuration.password = password if password
|
|
12
|
+
@configuration.environment = environment if environment
|
|
13
|
+
|
|
14
|
+
options.each do |key, value|
|
|
15
|
+
@configuration.public_send(:"#{key}=", value) if @configuration.respond_to?(:"#{key}=")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
@configuration.validate!
|
|
19
|
+
@connection = Connection.new(@configuration)
|
|
20
|
+
@cost_manager = CostManager.new(@configuration)
|
|
21
|
+
@services = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# --- Service accessors ---
|
|
25
|
+
|
|
26
|
+
def search_service
|
|
27
|
+
@services[:search] ||= Services::SearchService.new(@connection)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def report_service
|
|
31
|
+
@services[:report] ||= Services::ReportService.new(@connection)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def account_service
|
|
35
|
+
@services[:account] ||= Services::AccountService.new(@connection)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def informace_service
|
|
39
|
+
@services[:informace] ||= Services::InformaceService.new(@connection)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ciselnik_service
|
|
43
|
+
@services[:ciselnik] ||= Services::CiselnikService.new(@connection)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# --- Account management ---
|
|
47
|
+
|
|
48
|
+
def account_status
|
|
49
|
+
account_service.stav_ws
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def change_password(old_password:, new_password:)
|
|
53
|
+
account_service.zmen_heslo(stare_heslo: old_password, nove_heslo: new_password)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def cost_estimate(operations)
|
|
57
|
+
@cost_manager.estimate_batch_cost(operations)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- Search operations ---
|
|
61
|
+
|
|
62
|
+
def search_parcel(cadastral_unit_code: nil, parcel_number: nil, parcel_id: nil, **)
|
|
63
|
+
search_service.najdi_parcelu(
|
|
64
|
+
katastr_uzemi_kod: cadastral_unit_code,
|
|
65
|
+
kmenove_cislo: parcel_number,
|
|
66
|
+
parcela_id: parcel_id,
|
|
67
|
+
**
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def search_building(building_id: nil, district_part_code: nil, building_type_code: nil,
|
|
72
|
+
building_number: nil, **)
|
|
73
|
+
search_service.najdi_stavbu(
|
|
74
|
+
stavba_id: building_id,
|
|
75
|
+
cast_obce_kod: district_part_code,
|
|
76
|
+
typ_stavby_kod: building_type_code,
|
|
77
|
+
cislo_domovni: building_number,
|
|
78
|
+
**
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def search_unit(cadastral_unit_code: nil, unit_number: nil, unit_id: nil, **)
|
|
83
|
+
search_service.najdi_jednotku(
|
|
84
|
+
katastr_uzemi_kod: cadastral_unit_code,
|
|
85
|
+
cislo_jednotky: unit_number,
|
|
86
|
+
jednotka_id: unit_id,
|
|
87
|
+
**
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def search_subject(**params)
|
|
92
|
+
search_service.najdi_os(**params)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# --- Report generation (async) ---
|
|
96
|
+
|
|
97
|
+
def generate_lv(lv_id:, format: 'pdf', **options)
|
|
98
|
+
with_cost_control(:lv_extract_pdf) do
|
|
99
|
+
report_service.generuj_lv(lv_id: lv_id, format: format, **options)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def generate_parcel_info(format: 'pdf', **options)
|
|
104
|
+
with_cost_control(:property_info) do
|
|
105
|
+
report_service.generuj_info_o_parcelach(format: format, **options)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def generate_building_info(format: 'pdf', **options)
|
|
110
|
+
with_cost_control(:building_info) do
|
|
111
|
+
report_service.generuj_info_o_stavbach(format: format, **options)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def generate_unit_info(jednotka_id:, format: 'pdf', **options)
|
|
116
|
+
with_cost_control(:unit_info) do
|
|
117
|
+
report_service.generuj_info_o_jednotkach(jednotka_id: jednotka_id, format: format, **options)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Report management ---
|
|
122
|
+
|
|
123
|
+
def list_reports(id_sestavy: nil)
|
|
124
|
+
report_service.seznam_sestav(id_sestavy: id_sestavy)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def download_report(id_sestavy:)
|
|
128
|
+
report_service.vrat_sestavu(id_sestavy: id_sestavy)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def delete_report(id_sestavy:)
|
|
132
|
+
report_service.smaz_sestavu(id_sestavy: id_sestavy)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --- Codelist operations ---
|
|
136
|
+
|
|
137
|
+
def list_cadastral_units
|
|
138
|
+
ciselnik_service.seznam_ku
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# --- Batch processing ---
|
|
142
|
+
|
|
143
|
+
def batch_generate(operations, &progress_block)
|
|
144
|
+
processor = BatchProcessor.new(self, operations)
|
|
145
|
+
processor.on_progress(&progress_block) if progress_block
|
|
146
|
+
processor.start
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# --- Monitoring ---
|
|
150
|
+
|
|
151
|
+
def service_status
|
|
152
|
+
{
|
|
153
|
+
environment: @configuration.environment,
|
|
154
|
+
wsdp_version: @configuration.wsdp_version,
|
|
155
|
+
connected: connection_alive?,
|
|
156
|
+
cost_manager: {
|
|
157
|
+
daily_spent: @cost_manager.daily_spent,
|
|
158
|
+
monthly_spent: @cost_manager.monthly_spent,
|
|
159
|
+
daily_remaining: @cost_manager.daily_budget_remaining,
|
|
160
|
+
monthly_remaining: @cost_manager.monthly_budget_remaining,
|
|
161
|
+
budget_alert: @cost_manager.budget_alert?
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def wsdp_version_info
|
|
167
|
+
{
|
|
168
|
+
version: @configuration.wsdp_version,
|
|
169
|
+
available_services: @connection.wsdl_manager.available_services,
|
|
170
|
+
wsdl_urls: @connection.wsdl_manager.all_wsdl_urls,
|
|
171
|
+
soap_endpoints: @connection.wsdl_manager.all_soap_endpoints
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def with_cost_control(operation, count: 1)
|
|
178
|
+
estimated = @cost_manager.estimate_cost(operation, count: count)
|
|
179
|
+
|
|
180
|
+
@cost_manager.check_budget!(estimated) if @configuration.auto_pause_on_budget_exceeded
|
|
181
|
+
|
|
182
|
+
result = yield
|
|
183
|
+
|
|
184
|
+
@cost_manager.record_transaction(operation, estimated)
|
|
185
|
+
result
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def connection_alive?
|
|
189
|
+
account_service.stav_ws
|
|
190
|
+
true
|
|
191
|
+
rescue Error
|
|
192
|
+
false
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CUZK
|
|
4
|
+
module SOAP
|
|
5
|
+
class Configuration
|
|
6
|
+
# WSDP account credentials
|
|
7
|
+
attr_accessor :username, :password
|
|
8
|
+
|
|
9
|
+
# Environment: :production or :testing
|
|
10
|
+
attr_accessor :environment
|
|
11
|
+
|
|
12
|
+
# WSDP version: :v31 or :v29
|
|
13
|
+
attr_accessor :wsdp_version
|
|
14
|
+
|
|
15
|
+
# Cost management (CZK)
|
|
16
|
+
attr_accessor :daily_budget_limit, :monthly_budget_limit,
|
|
17
|
+
:cost_alert_threshold, :auto_pause_on_budget_exceeded
|
|
18
|
+
|
|
19
|
+
# SOAP client settings (WSDP uses SOAP 1.1 only)
|
|
20
|
+
attr_accessor :open_timeout, :read_timeout,
|
|
21
|
+
:max_retries, :retry_delay,
|
|
22
|
+
:ssl_verify_mode
|
|
23
|
+
|
|
24
|
+
# WSDP features
|
|
25
|
+
attr_accessor :validate_wsdl_on_startup, :cache_wsdl, :wsdl_cache_ttl
|
|
26
|
+
|
|
27
|
+
# Enterprise features
|
|
28
|
+
attr_accessor :audit_logging, :audit_log_path,
|
|
29
|
+
:performance_monitoring, :compliance_mode, :gdpr_anonymization
|
|
30
|
+
|
|
31
|
+
# Caching
|
|
32
|
+
attr_accessor :cache_store, :cache_ttl_extracts, :cache_ttl_searches,
|
|
33
|
+
:cache_ttl_account_status
|
|
34
|
+
|
|
35
|
+
# Logging
|
|
36
|
+
attr_accessor :logger, :log_level
|
|
37
|
+
|
|
38
|
+
# Rate limiting
|
|
39
|
+
attr_accessor :requests_per_minute, :concurrent_requests
|
|
40
|
+
|
|
41
|
+
# Circuit breaker
|
|
42
|
+
attr_accessor :circuit_breaker_threshold, :circuit_breaker_timeout
|
|
43
|
+
|
|
44
|
+
# Enabled services
|
|
45
|
+
attr_accessor :enabled_services
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@environment = :production
|
|
49
|
+
@wsdp_version = :v31
|
|
50
|
+
|
|
51
|
+
# Cost management defaults
|
|
52
|
+
@daily_budget_limit = nil
|
|
53
|
+
@monthly_budget_limit = nil
|
|
54
|
+
@cost_alert_threshold = 0.8
|
|
55
|
+
@auto_pause_on_budget_exceeded = false
|
|
56
|
+
|
|
57
|
+
# SOAP defaults (WSDP uses SOAP 1.1 exclusively)
|
|
58
|
+
@open_timeout = 30
|
|
59
|
+
@read_timeout = 120
|
|
60
|
+
@max_retries = 3
|
|
61
|
+
@retry_delay = 2
|
|
62
|
+
@ssl_verify_mode = :peer
|
|
63
|
+
|
|
64
|
+
# WSDP feature defaults
|
|
65
|
+
@validate_wsdl_on_startup = false
|
|
66
|
+
@cache_wsdl = true
|
|
67
|
+
@wsdl_cache_ttl = 86_400 # 24 hours in seconds
|
|
68
|
+
|
|
69
|
+
# Enterprise defaults
|
|
70
|
+
@audit_logging = false
|
|
71
|
+
@performance_monitoring = false
|
|
72
|
+
@compliance_mode = :standard
|
|
73
|
+
@gdpr_anonymization = true
|
|
74
|
+
|
|
75
|
+
# Caching defaults (seconds)
|
|
76
|
+
@cache_ttl_extracts = 1800 # 30 minutes
|
|
77
|
+
@cache_ttl_searches = 900 # 15 minutes
|
|
78
|
+
@cache_ttl_account_status = 300 # 5 minutes
|
|
79
|
+
|
|
80
|
+
# Logging defaults
|
|
81
|
+
@log_level = :info
|
|
82
|
+
|
|
83
|
+
# Rate limiting defaults
|
|
84
|
+
@requests_per_minute = 120
|
|
85
|
+
@concurrent_requests = 10
|
|
86
|
+
|
|
87
|
+
# Circuit breaker defaults
|
|
88
|
+
@circuit_breaker_threshold = 10
|
|
89
|
+
@circuit_breaker_timeout = 60
|
|
90
|
+
|
|
91
|
+
# All services enabled by default
|
|
92
|
+
@enabled_services = %i[vyhledat sestavy informace ciselnik ucet]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def production?
|
|
96
|
+
environment == :production
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def testing?
|
|
100
|
+
environment == :testing
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate!
|
|
104
|
+
raise ConfigurationError, 'username is required' if username.nil? || username.empty?
|
|
105
|
+
raise ConfigurationError, 'password is required' if password.nil? || password.empty?
|
|
106
|
+
|
|
107
|
+
unless %i[production testing].include?(environment)
|
|
108
|
+
raise ConfigurationError, 'environment must be :production or :testing'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
raise ConfigurationError, 'wsdp_version must be :v31 or :v29' unless %i[v31 v29].include?(wsdp_version)
|
|
112
|
+
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'savon'
|
|
4
|
+
require 'retriable'
|
|
5
|
+
|
|
6
|
+
module CUZK
|
|
7
|
+
module SOAP
|
|
8
|
+
class Connection
|
|
9
|
+
attr_reader :wsdl_manager, :credentials
|
|
10
|
+
|
|
11
|
+
def initialize(configuration)
|
|
12
|
+
@configuration = configuration
|
|
13
|
+
@wsdl_manager = WSDLManager.new(
|
|
14
|
+
version: configuration.wsdp_version,
|
|
15
|
+
environment: configuration.environment
|
|
16
|
+
)
|
|
17
|
+
@credentials = Authentication::WSDPCredentials.new(
|
|
18
|
+
username: configuration.username,
|
|
19
|
+
password: configuration.password,
|
|
20
|
+
environment: configuration.environment
|
|
21
|
+
)
|
|
22
|
+
@clients = {}
|
|
23
|
+
@circuit_breaker_failures = 0
|
|
24
|
+
@circuit_breaker_opened_at = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(service, operation, message = {})
|
|
28
|
+
check_circuit_breaker!
|
|
29
|
+
|
|
30
|
+
with_retry do
|
|
31
|
+
client = client_for(service)
|
|
32
|
+
response = client.call(operation, message: message)
|
|
33
|
+
reset_circuit_breaker
|
|
34
|
+
response
|
|
35
|
+
end
|
|
36
|
+
rescue Savon::SOAPFault => e
|
|
37
|
+
handle_soap_fault(e)
|
|
38
|
+
rescue Savon::HTTPError => e
|
|
39
|
+
record_failure
|
|
40
|
+
raise ServiceUnavailableError.new(
|
|
41
|
+
"HTTP error: #{e.message}",
|
|
42
|
+
fault_code: e.http.code.to_s
|
|
43
|
+
)
|
|
44
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
45
|
+
record_failure
|
|
46
|
+
raise NetworkTimeoutError, "Request timed out: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def client_for(service)
|
|
50
|
+
@clients[service] ||= build_savon_client(service)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def available_operations(service)
|
|
54
|
+
client_for(service).operations
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
raise WSDPError, "Failed to retrieve operations for #{service}: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_savon_client(service)
|
|
62
|
+
wsdl_url = @wsdl_manager.wsdl_url(service)
|
|
63
|
+
# ČÚZK WSDLs use separate namespaces for service definitions and types.
|
|
64
|
+
# Savon defaults to the WSDL targetNamespace (services:*) but request
|
|
65
|
+
# elements must use the types namespace (types:*).
|
|
66
|
+
types_ns = @wsdl_manager.types_namespace(service)
|
|
67
|
+
|
|
68
|
+
Savon.client(
|
|
69
|
+
wsdl: wsdl_url,
|
|
70
|
+
namespace: types_ns,
|
|
71
|
+
wsse_auth: @credentials.savon_wsse_auth,
|
|
72
|
+
soap_version: 1, # WSDP uses SOAP 1.1 exclusively
|
|
73
|
+
open_timeout: @configuration.open_timeout,
|
|
74
|
+
read_timeout: @configuration.read_timeout,
|
|
75
|
+
encoding: 'UTF-8',
|
|
76
|
+
log: !@configuration.logger.nil?,
|
|
77
|
+
logger: @configuration.logger || Logger.new(File::NULL),
|
|
78
|
+
log_level: @configuration.log_level,
|
|
79
|
+
pretty_print_xml: true,
|
|
80
|
+
ssl_verify_mode: @configuration.ssl_verify_mode,
|
|
81
|
+
convert_request_keys_to: :none
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def with_retry(&)
|
|
86
|
+
Retriable.retriable(
|
|
87
|
+
tries: @configuration.max_retries + 1,
|
|
88
|
+
base_interval: @configuration.retry_delay,
|
|
89
|
+
multiplier: 2.0,
|
|
90
|
+
on: [NetworkTimeoutError, ServiceUnavailableError],
|
|
91
|
+
&
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handle_soap_fault(error)
|
|
96
|
+
fault = error.to_hash[:fault] || {}
|
|
97
|
+
fault_code = fault[:faultcode].to_s
|
|
98
|
+
fault_string = fault[:faultstring].to_s
|
|
99
|
+
|
|
100
|
+
exception_class = case fault_code
|
|
101
|
+
when /InsufficientFunds/i then InsufficientFundsError
|
|
102
|
+
when /AccountSuspended/i then AccountSuspendedError
|
|
103
|
+
when /Authentication/i then AuthenticationError
|
|
104
|
+
when /InvalidRequest/i then InvalidRequestError
|
|
105
|
+
else WSDPError
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
record_failure
|
|
109
|
+
raise exception_class.new(fault_string, fault_code: fault_code, fault_string: fault_string)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def check_circuit_breaker!
|
|
113
|
+
return unless @circuit_breaker_opened_at
|
|
114
|
+
|
|
115
|
+
elapsed = Time.now - @circuit_breaker_opened_at
|
|
116
|
+
if elapsed >= @configuration.circuit_breaker_timeout
|
|
117
|
+
@circuit_breaker_opened_at = nil
|
|
118
|
+
@circuit_breaker_failures = 0
|
|
119
|
+
else
|
|
120
|
+
raise CircuitBreakerOpenError,
|
|
121
|
+
"Circuit breaker is open. Retry after #{(@configuration.circuit_breaker_timeout - elapsed).ceil}s"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def record_failure
|
|
126
|
+
@circuit_breaker_failures += 1
|
|
127
|
+
return unless @circuit_breaker_failures >= @configuration.circuit_breaker_threshold
|
|
128
|
+
|
|
129
|
+
@circuit_breaker_opened_at = Time.now
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def reset_circuit_breaker
|
|
133
|
+
@circuit_breaker_failures = 0
|
|
134
|
+
@circuit_breaker_opened_at = nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CUZK
|
|
4
|
+
module SOAP
|
|
5
|
+
class CostManager
|
|
6
|
+
# Approximate WSDP pricing in CZK (actual prices per ČÚZK sazebník)
|
|
7
|
+
WSDP_PRICING = {
|
|
8
|
+
lv_extract_pdf: 50.0,
|
|
9
|
+
lv_extract_xml: 50.0,
|
|
10
|
+
property_info: 30.0,
|
|
11
|
+
building_info: 30.0,
|
|
12
|
+
unit_info: 30.0,
|
|
13
|
+
ownership_overview: 100.0,
|
|
14
|
+
ownership_with_properties: 100.0,
|
|
15
|
+
price_data: 40.0,
|
|
16
|
+
account_status: 0.0,
|
|
17
|
+
document_order: 0.0
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :daily_spent, :monthly_spent, :transaction_log
|
|
21
|
+
|
|
22
|
+
def initialize(configuration)
|
|
23
|
+
@configuration = configuration
|
|
24
|
+
@daily_spent = 0.0
|
|
25
|
+
@monthly_spent = 0.0
|
|
26
|
+
@transaction_log = []
|
|
27
|
+
@daily_reset_at = today_start
|
|
28
|
+
@monthly_reset_at = month_start
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def estimate_cost(operation, count: 1)
|
|
32
|
+
unit_cost = WSDP_PRICING.fetch(operation, 0.0)
|
|
33
|
+
unit_cost * count
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def estimate_batch_cost(operations)
|
|
37
|
+
operations.sum { |op, count| estimate_cost(op, count: count) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def check_budget!(estimated_cost)
|
|
41
|
+
reset_counters_if_needed
|
|
42
|
+
|
|
43
|
+
if @configuration.daily_budget_limit && (daily_spent + estimated_cost) > @configuration.daily_budget_limit
|
|
44
|
+
raise CostLimitExceededError,
|
|
45
|
+
"Daily budget limit would be exceeded: #{daily_spent + estimated_cost} CZK " \
|
|
46
|
+
"(limit: #{@configuration.daily_budget_limit} CZK)"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if @configuration.monthly_budget_limit && (monthly_spent + estimated_cost) > @configuration.monthly_budget_limit
|
|
50
|
+
raise CostLimitExceededError,
|
|
51
|
+
"Monthly budget limit would be exceeded: #{monthly_spent + estimated_cost} CZK " \
|
|
52
|
+
"(limit: #{@configuration.monthly_budget_limit} CZK)"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def record_transaction(operation, cost, metadata = {})
|
|
59
|
+
reset_counters_if_needed
|
|
60
|
+
|
|
61
|
+
@daily_spent += cost
|
|
62
|
+
@monthly_spent += cost
|
|
63
|
+
@transaction_log << {
|
|
64
|
+
operation: operation,
|
|
65
|
+
cost: cost,
|
|
66
|
+
timestamp: Time.now.utc,
|
|
67
|
+
metadata: metadata
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def budget_alert?
|
|
72
|
+
return false unless @configuration.cost_alert_threshold
|
|
73
|
+
|
|
74
|
+
threshold = @configuration.cost_alert_threshold
|
|
75
|
+
|
|
76
|
+
if @configuration.daily_budget_limit && (daily_spent >= (@configuration.daily_budget_limit * threshold))
|
|
77
|
+
return true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if @configuration.monthly_budget_limit && (monthly_spent >= (@configuration.monthly_budget_limit * threshold))
|
|
81
|
+
return true
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def daily_budget_remaining
|
|
88
|
+
return Float::INFINITY unless @configuration.daily_budget_limit
|
|
89
|
+
|
|
90
|
+
[@configuration.daily_budget_limit - daily_spent, 0.0].max
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def monthly_budget_remaining
|
|
94
|
+
return Float::INFINITY unless @configuration.monthly_budget_limit
|
|
95
|
+
|
|
96
|
+
[@configuration.monthly_budget_limit - monthly_spent, 0.0].max
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def reset_daily!
|
|
100
|
+
@daily_spent = 0.0
|
|
101
|
+
@daily_reset_at = today_start
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def reset_monthly!
|
|
105
|
+
@monthly_spent = 0.0
|
|
106
|
+
@monthly_reset_at = month_start
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def reset_counters_if_needed
|
|
112
|
+
now = Time.now.utc
|
|
113
|
+
|
|
114
|
+
if now >= @daily_reset_at + 86_400
|
|
115
|
+
@daily_spent = 0.0
|
|
116
|
+
@daily_reset_at = today_start
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return unless now.month != @monthly_reset_at.month || now.year != @monthly_reset_at.year
|
|
120
|
+
|
|
121
|
+
@monthly_spent = 0.0
|
|
122
|
+
@monthly_reset_at = month_start
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def today_start
|
|
126
|
+
now = Time.now.utc
|
|
127
|
+
Time.utc(now.year, now.month, now.day)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def month_start
|
|
131
|
+
now = Time.now.utc
|
|
132
|
+
Time.utc(now.year, now.month, 1)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module CUZK
|
|
7
|
+
module SOAP
|
|
8
|
+
class DocumentManager
|
|
9
|
+
attr_reader :storage_path
|
|
10
|
+
|
|
11
|
+
def initialize(storage_path: nil)
|
|
12
|
+
@storage_path = storage_path
|
|
13
|
+
@documents = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def store(document_id, content, format:, metadata: {})
|
|
17
|
+
entry = {
|
|
18
|
+
content: content,
|
|
19
|
+
format: format,
|
|
20
|
+
stored_at: Time.now.utc,
|
|
21
|
+
size: content.bytesize,
|
|
22
|
+
metadata: metadata
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@documents[document_id] = entry
|
|
26
|
+
|
|
27
|
+
write_to_disk(document_id, content, format) if @storage_path
|
|
28
|
+
|
|
29
|
+
entry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def retrieve(document_id)
|
|
33
|
+
entry = @documents[document_id]
|
|
34
|
+
return entry if entry
|
|
35
|
+
|
|
36
|
+
return unless @storage_path
|
|
37
|
+
|
|
38
|
+
read_from_disk(document_id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def delete(document_id)
|
|
42
|
+
@documents.delete(document_id)
|
|
43
|
+
|
|
44
|
+
return unless @storage_path
|
|
45
|
+
|
|
46
|
+
delete_from_disk(document_id)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def list(filter: nil)
|
|
50
|
+
docs = @documents.map do |id, entry|
|
|
51
|
+
{
|
|
52
|
+
document_id: id,
|
|
53
|
+
format: entry[:format],
|
|
54
|
+
stored_at: entry[:stored_at],
|
|
55
|
+
size: entry[:size],
|
|
56
|
+
metadata: entry[:metadata]
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if filter
|
|
61
|
+
docs.select { |d| d[:format].to_s == filter.to_s }
|
|
62
|
+
else
|
|
63
|
+
docs
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def save_to_file(document_id, file_path)
|
|
68
|
+
entry = retrieve(document_id)
|
|
69
|
+
raise DocumentError, "Document not found: #{document_id}" unless entry
|
|
70
|
+
|
|
71
|
+
File.binwrite(file_path, entry[:content])
|
|
72
|
+
file_path
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def write_to_disk(document_id, content, format)
|
|
78
|
+
FileUtils.mkdir_p(@storage_path)
|
|
79
|
+
file_path = File.join(@storage_path, "#{sanitize_id(document_id)}.#{format}")
|
|
80
|
+
File.binwrite(file_path, content)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def read_from_disk(document_id)
|
|
84
|
+
Dir.glob(File.join(@storage_path, "#{sanitize_id(document_id)}.*")).first&.then do |path|
|
|
85
|
+
{
|
|
86
|
+
content: File.binread(path),
|
|
87
|
+
format: File.extname(path).delete('.'),
|
|
88
|
+
stored_at: File.mtime(path),
|
|
89
|
+
size: File.size(path),
|
|
90
|
+
metadata: {}
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def delete_from_disk(document_id)
|
|
96
|
+
Dir.glob(File.join(@storage_path, "#{sanitize_id(document_id)}.*")).each do |path|
|
|
97
|
+
File.delete(path)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sanitize_id(document_id)
|
|
102
|
+
document_id.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|