ox-tender-abstract 0.0.1

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.
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "openssl"
6
+ require "zlib"
7
+ require "stringio"
8
+ require "zip"
9
+
10
+ module OxTenderAbstract
11
+ # Archive processor for downloading and extracting archive files
12
+ class ArchiveProcessor
13
+ include ContextualLogger
14
+
15
+ MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB in bytes
16
+
17
+ def initialize
18
+ # Archive processor initialization
19
+ end
20
+
21
+ # Download and extract archive data
22
+ def download_and_extract(archive_url)
23
+ return Result.failure("Empty archive URL") if archive_url.nil? || archive_url.empty?
24
+
25
+ begin
26
+ # Download archive to memory
27
+ download_result = download_to_memory(archive_url)
28
+ return download_result if download_result.failure?
29
+
30
+ content = download_result.data[:content]
31
+
32
+ # Determine archive format by first bytes
33
+ first_bytes = content[0..1].unpack("H*").first
34
+
35
+ if first_bytes == "1f8b"
36
+ # This is GZIP archive - decompress GZIP, then ZIP
37
+ gunzip_result = decompress_gzip(content)
38
+ return gunzip_result if gunzip_result.failure?
39
+
40
+ zip_result = extract_zip_from_memory(gunzip_result.data[:content])
41
+
42
+ Result.success({
43
+ files: zip_result,
44
+ total_size: download_result.data[:size],
45
+ compressed_size: gunzip_result.data[:compressed_size],
46
+ file_count: zip_result.size
47
+ })
48
+ elsif content[0..1] == "PK"
49
+ # This is already ZIP archive - parse directly
50
+ zip_result = extract_zip_from_memory(content)
51
+
52
+ Result.success({
53
+ files: zip_result,
54
+ total_size: download_result.data[:size],
55
+ compressed_size: nil,
56
+ file_count: zip_result.size
57
+ })
58
+ else
59
+ Result.failure("Unknown archive format (not GZIP and not ZIP)")
60
+ end
61
+ rescue => e
62
+ Result.failure("Archive processing error: #{e.message}")
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def download_to_memory(url)
69
+ begin
70
+ uri = URI.parse(url)
71
+ # Check if URI is valid HTTP/HTTPS
72
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
73
+ return Result.failure("Invalid URL: not HTTP/HTTPS")
74
+ end
75
+ rescue URI::InvalidURIError => e
76
+ return Result.failure("Invalid URL: #{e.message}")
77
+ end
78
+
79
+ begin
80
+ http = create_http_client(uri)
81
+
82
+ request = Net::HTTP::Get.new(uri.request_uri)
83
+ request["User-Agent"] = "OxTenderAbstract/#{OxTenderAbstract::VERSION}"
84
+ request["individualPerson_token"] = OxTenderAbstract.configuration.token
85
+
86
+ log_debug "Downloading archive from: #{url}"
87
+
88
+ response = http.request(request)
89
+
90
+ unless response.is_a?(Net::HTTPSuccess)
91
+ return Result.failure("HTTP error: #{response.code} #{response.message}")
92
+ end
93
+
94
+ content = response.body
95
+ size = content.bytesize
96
+
97
+ if size > MAX_FILE_SIZE_BYTES
98
+ return Result.failure("Archive too large: #{size} bytes (max: #{MAX_FILE_SIZE_BYTES})")
99
+ end
100
+
101
+ log_debug "Downloaded archive: #{size} bytes"
102
+
103
+ Result.success({
104
+ content: content,
105
+ size: size,
106
+ content_type: response["content-type"]
107
+ })
108
+ rescue SocketError, Timeout::Error => e
109
+ Result.failure("Network error: #{e.message}")
110
+ rescue => e
111
+ Result.failure("Download error: #{e.message}")
112
+ end
113
+ end
114
+
115
+ def create_http_client(uri)
116
+ http = Net::HTTP.new(uri.host, uri.port)
117
+ http.use_ssl = uri.scheme == "https"
118
+ http.verify_mode = OxTenderAbstract.configuration.ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
119
+ http.open_timeout = OxTenderAbstract.configuration.timeout_open
120
+ http.read_timeout = OxTenderAbstract.configuration.timeout_read
121
+ http
122
+ end
123
+
124
+ def decompress_gzip(gzip_content)
125
+ begin
126
+ log_debug "Decompressing GZIP archive"
127
+
128
+ gz = Zlib::GzipReader.new(StringIO.new(gzip_content))
129
+ decompressed_content = gz.read
130
+ gz.close
131
+
132
+ Result.success({
133
+ content: decompressed_content,
134
+ compressed_size: gzip_content.bytesize,
135
+ decompressed_size: decompressed_content.bytesize
136
+ })
137
+ rescue Zlib::GzipFile::Error => e
138
+ Result.failure("GZIP decompression error: #{e.message}")
139
+ rescue => e
140
+ Result.failure("Decompression error: #{e.message}")
141
+ end
142
+ end
143
+
144
+ def extract_zip_from_memory(zip_content)
145
+ begin
146
+ log_debug "Extracting ZIP archive from memory"
147
+
148
+ files = {}
149
+ zip_io = StringIO.new(zip_content)
150
+
151
+ Zip::File.open_buffer(zip_io) do |zip_file|
152
+ zip_file.each do |entry|
153
+ next if entry.directory?
154
+
155
+ log_debug "Extracting file: #{entry.name} (#{entry.size} bytes)"
156
+
157
+ files[entry.name] = {
158
+ content: entry.get_input_stream.read,
159
+ size: entry.size,
160
+ compressed_size: entry.compressed_size,
161
+ crc: entry.crc
162
+ }
163
+ end
164
+ end
165
+
166
+ log_debug "Extracted #{files.size} files from ZIP archive"
167
+ files
168
+ rescue Zip::Error => e
169
+ raise ArchiveError, "ZIP extraction error: #{e.message}"
170
+ rescue => e
171
+ raise ArchiveError, "Archive extraction error: #{e.message}"
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,347 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'document_types'
4
+ require_relative 'result'
5
+ require_relative 'errors'
6
+ require_relative 'archive_processor'
7
+ require_relative 'xml_parser'
8
+ require_relative 'logger'
9
+ require 'savon'
10
+ require 'securerandom'
11
+ require 'net/http'
12
+ require 'uri'
13
+ require 'openssl'
14
+
15
+ module OxTenderAbstract
16
+ # Main client for working with Zakupki SOAP API
17
+ class Client
18
+ include ContextualLogger
19
+
20
+ def initialize(token: nil)
21
+ @token = token || OxTenderAbstract.configuration.token
22
+ @xml_parser = XmlParser.new
23
+ @archive_processor = ArchiveProcessor.new
24
+ validate_token!
25
+ end
26
+
27
+ # Get documents by region and date
28
+ def get_docs_by_region(org_region:, exact_date:, subsystem_type: DocumentTypes::DEFAULT_SUBSYSTEM,
29
+ document_type: DocumentTypes::DEFAULT_DOCUMENT_TYPE)
30
+ validate_params!({
31
+ org_region: org_region,
32
+ subsystem_type: subsystem_type,
33
+ document_type: document_type,
34
+ exact_date: exact_date
35
+ })
36
+
37
+ request_data = build_region_request(org_region, subsystem_type, document_type, exact_date)
38
+ execute_soap_request(:get_docs_by_org_region, request_data)
39
+ end
40
+
41
+ # Get documents by registry number
42
+ def get_docs_by_reestr_number(reestr_number:, subsystem_type: DocumentTypes::DEFAULT_SUBSYSTEM)
43
+ validate_params!({
44
+ reestr_number: reestr_number,
45
+ subsystem_type: subsystem_type
46
+ })
47
+
48
+ request_data = build_reestr_request(reestr_number, subsystem_type)
49
+ log_info "Requesting documents for registry number: #{reestr_number}, type: #{subsystem_type}"
50
+
51
+ result = execute_soap_request(:get_docs_by_reestr_number, request_data)
52
+
53
+ if result.success?
54
+ log_info "Success response for #{reestr_number}. Found archives: #{result.data[:archive_urls]&.size || 0}"
55
+ else
56
+ log_error "Error for #{reestr_number}: #{result.error}"
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ # Download and parse archive data
63
+ def download_archive_data(archive_url)
64
+ @archive_processor.download_and_extract(archive_url)
65
+ end
66
+
67
+ # Parse XML document
68
+ def parse_xml_document(xml_content)
69
+ @xml_parser.parse(xml_content)
70
+ end
71
+
72
+ # Extract attachments info from XML
73
+ def extract_attachments_from_xml(xml_content)
74
+ @xml_parser.extract_attachments(xml_content)
75
+ end
76
+
77
+ # Search tenders with full workflow: API -> Archive -> Parse
78
+ def search_tenders(org_region:, exact_date:, subsystem_type: DocumentTypes::DEFAULT_SUBSYSTEM,
79
+ document_type: DocumentTypes::DEFAULT_DOCUMENT_TYPE)
80
+ log_info "Starting tender search for region #{org_region}, date #{exact_date}"
81
+
82
+ # Step 1: Get archive URLs from API
83
+ api_result = get_docs_by_region(
84
+ org_region: org_region,
85
+ subsystem_type: subsystem_type,
86
+ document_type: document_type,
87
+ exact_date: exact_date
88
+ )
89
+
90
+ return api_result if api_result.failure?
91
+
92
+ archive_urls = api_result.data[:archive_urls]
93
+ return Result.success({ tenders: [], total_archives: 0, total_files: 0 }) if archive_urls.empty?
94
+
95
+ log_info "Found #{archive_urls.size} archives to process"
96
+
97
+ # Step 2: Process each archive
98
+ all_tenders = []
99
+ total_files = 0
100
+
101
+ archive_urls.each_with_index do |archive_url, index|
102
+ log_info "Processing archive #{index + 1}/#{archive_urls.size}"
103
+
104
+ archive_result = download_archive_data(archive_url)
105
+ next if archive_result.failure?
106
+
107
+ files = archive_result.data[:files]
108
+ total_files += files.size
109
+
110
+ # Step 3: Parse XML files from archive
111
+ xml_files = files.select { |name, _| name.downcase.end_with?('.xml') }
112
+
113
+ xml_files.each do |file_name, file_data|
114
+ parse_result = parse_xml_document(file_data[:content])
115
+ next if parse_result.failure?
116
+ next unless parse_result.data[:document_type] == :tender
117
+
118
+ tender_data = parse_result.data[:content]
119
+ next if tender_data[:reestr_number].nil? || tender_data[:reestr_number].empty?
120
+
121
+ # Add metadata
122
+ tender_data[:source_file] = file_name
123
+ tender_data[:archive_url] = archive_url
124
+ tender_data[:processed_at] = Time.now
125
+
126
+ all_tenders << tender_data
127
+ end
128
+ end
129
+
130
+ log_info "Search completed. Found #{all_tenders.size} tenders in #{total_files} files"
131
+
132
+ Result.success({
133
+ tenders: all_tenders,
134
+ total_archives: archive_urls.size,
135
+ total_files: total_files,
136
+ processed_at: Time.now
137
+ })
138
+ end
139
+
140
+ # Enhanced search tenders with detailed information extraction
141
+ def enhanced_search_tenders(org_region:, exact_date:, subsystem_type: DocumentTypes::DEFAULT_SUBSYSTEM,
142
+ document_type: DocumentTypes::DEFAULT_DOCUMENT_TYPE,
143
+ include_attachments: true)
144
+ log_info "Starting enhanced tender search for region #{org_region}, date #{exact_date}"
145
+
146
+ # Step 1: Get archive URLs from API
147
+ api_result = get_docs_by_region(
148
+ org_region: org_region,
149
+ subsystem_type: subsystem_type,
150
+ document_type: document_type,
151
+ exact_date: exact_date
152
+ )
153
+
154
+ return api_result if api_result.failure?
155
+
156
+ archive_urls = api_result.data[:archive_urls]
157
+ return Result.success({ tenders: [], total_archives: 0, total_files: 0 }) if archive_urls.empty?
158
+
159
+ log_info "Found #{archive_urls.size} archives to process"
160
+
161
+ # Step 2: Process each archive with detailed information extraction
162
+ all_tenders = []
163
+ total_files = 0
164
+
165
+ archive_urls.each_with_index do |archive_url, index|
166
+ log_info "Processing archive #{index + 1}/#{archive_urls.size}"
167
+
168
+ archive_result = download_archive_data(archive_url)
169
+ next if archive_result.failure?
170
+
171
+ files = archive_result.data[:files]
172
+ total_files += files.size
173
+
174
+ # Step 3: Parse XML files from archive with enhanced data extraction
175
+ xml_files = files.select { |name, _| name.downcase.end_with?('.xml') }
176
+
177
+ xml_files.each do |file_name, file_data|
178
+ parse_result = parse_xml_document(file_data[:content])
179
+ next if parse_result.failure?
180
+ next unless parse_result.data[:document_type] == :tender
181
+
182
+ tender_data = parse_result.data[:content]
183
+ next if tender_data[:reestr_number].nil? || tender_data[:reestr_number].empty?
184
+
185
+ # Step 4: Extract additional detailed information
186
+ if include_attachments
187
+ attachments_result = extract_attachments_from_xml(file_data[:content])
188
+ if attachments_result.success?
189
+ tender_data[:attachments] = attachments_result.data[:attachments]
190
+ tender_data[:attachments_count] = attachments_result.data[:total_count]
191
+ end
192
+ end
193
+
194
+ # Add metadata
195
+ tender_data[:source_file] = file_name
196
+ tender_data[:archive_url] = archive_url
197
+ tender_data[:processed_at] = Time.now
198
+
199
+ all_tenders << tender_data
200
+ end
201
+ end
202
+
203
+ log_info "Enhanced search completed. Found #{all_tenders.size} tenders in #{total_files} files"
204
+
205
+ Result.success({
206
+ tenders: all_tenders,
207
+ total_archives: archive_urls.size,
208
+ total_files: total_files,
209
+ processed_at: Time.now,
210
+ enhanced: true
211
+ })
212
+ end
213
+
214
+ private
215
+
216
+ def validate_token!
217
+ return if @token&.strip&.length&.positive?
218
+
219
+ raise AuthenticationError, 'Token cannot be empty. Set it via OxTenderAbstract.configure or pass as parameter'
220
+ end
221
+
222
+ def validate_params!(params)
223
+ params.each do |key, value|
224
+ raise ConfigurationError, "Parameter #{key} cannot be blank" if value.nil? || value.to_s.strip.empty?
225
+ end
226
+ end
227
+
228
+ def build_region_request(org_region, subsystem_type, document_type, exact_date)
229
+ {
230
+ 'index' => {
231
+ 'id' => SecureRandom.uuid,
232
+ 'createDateTime' => Time.now.strftime('%Y-%m-%dT%H:%M:%S%:z'),
233
+ 'mode' => 'PROD'
234
+ },
235
+ 'selectionParams' => {
236
+ 'orgRegion' => org_region,
237
+ 'subsystemType' => subsystem_type,
238
+ 'documentType44' => document_type,
239
+ 'periodInfo' => {
240
+ 'exactDate' => exact_date
241
+ }
242
+ }
243
+ }
244
+ end
245
+
246
+ def build_reestr_request(reestr_number, subsystem_type)
247
+ {
248
+ 'index' => {
249
+ 'id' => SecureRandom.uuid,
250
+ 'createDateTime' => Time.now.strftime('%Y-%m-%dT%H:%M:%S%:z'),
251
+ 'mode' => 'PROD'
252
+ },
253
+ 'selectionParams' => {
254
+ 'subsystemType' => subsystem_type,
255
+ 'registryNumber' => reestr_number
256
+ }
257
+ }
258
+ end
259
+
260
+ def execute_soap_request(operation, message)
261
+ client = create_soap_client
262
+ log_debug "Executing SOAP request: #{operation}"
263
+ log_debug "SOAP header: #{client.globals[:soap_header]}"
264
+ log_debug "Request message: #{message.inspect}"
265
+
266
+ response = client.call(operation, message: message)
267
+ log_debug "SOAP response code: #{response.http.code}"
268
+ log_debug "SOAP response body keys: #{response.body.keys}"
269
+
270
+ process_soap_response(response, operation)
271
+ rescue Savon::SOAPFault => e
272
+ log_error "SOAP Fault: #{e.message}"
273
+ log_error "SOAP Fault details: #{e.to_hash}"
274
+ Result.failure("SOAP Fault: #{e.message}")
275
+ rescue Savon::HTTPError => e
276
+ log_error "HTTP Error: #{e.message}"
277
+ Result.failure("HTTP Error: #{e.message}")
278
+ rescue StandardError => e
279
+ log_error "General request error: #{e.message}"
280
+ log_error "#{e.backtrace.first(5).join("\n")}"
281
+ Result.failure("Request error: #{e.message}")
282
+ end
283
+
284
+ def create_soap_client
285
+ token_status = !@token.nil? && !@token.empty? ? "present (#{@token[0..5]}...)" : 'missing'
286
+ log_debug "Creating SOAP client with token: #{token_status}"
287
+
288
+ Savon.client(
289
+ wsdl: OxTenderAbstract.configuration.wsdl_url,
290
+ soap_header: { 'individualPerson_token' => @token },
291
+ open_timeout: OxTenderAbstract.configuration.timeout_open,
292
+ read_timeout: OxTenderAbstract.configuration.timeout_read,
293
+ ssl_verify_mode: OxTenderAbstract.configuration.ssl_verify ? :peer : :none,
294
+ log: false
295
+ )
296
+ end
297
+
298
+ def process_soap_response(response, operation)
299
+ return Result.failure('Empty response') unless response&.body
300
+
301
+ case operation
302
+ when :get_docs_by_org_region
303
+ process_region_response(response.body)
304
+ when :get_docs_by_reestr_number
305
+ process_reestr_response(response.body)
306
+ else
307
+ Result.failure("Unknown operation: #{operation}")
308
+ end
309
+ end
310
+
311
+ def process_region_response(body)
312
+ data_info = body.dig(:get_docs_by_org_region_response, :data_info)
313
+ return Result.failure('No data info in response') unless data_info
314
+
315
+ archive_urls = extract_archive_urls(data_info)
316
+
317
+ Result.success({
318
+ archive_urls: archive_urls,
319
+ response_metadata: {
320
+ operation: :get_docs_by_org_region,
321
+ timestamp: Time.now
322
+ }
323
+ })
324
+ end
325
+
326
+ def process_reestr_response(body)
327
+ data_info = body.dig(:get_docs_by_reestr_number_response, :data_info)
328
+ return Result.failure('No data info in response') unless data_info
329
+
330
+ archive_urls = extract_archive_urls(data_info)
331
+
332
+ Result.success({
333
+ archive_urls: archive_urls,
334
+ response_metadata: {
335
+ operation: :get_docs_by_reestr_number,
336
+ timestamp: Time.now
337
+ }
338
+ })
339
+ end
340
+
341
+ def extract_archive_urls(data_info)
342
+ return [] unless data_info&.dig(:archive_url)
343
+
344
+ Array(data_info[:archive_url]).compact
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruby
4
+ module OxTenderAbstract
5
+ VERSION = ::OxTenderAbstract::VERSION
6
+
7
+ Error = ::OxTenderAbstract::Error
8
+ ConfigurationError = ::OxTenderAbstract::ConfigurationError
9
+ Configuration = ::OxTenderAbstract::Configuration
10
+ end
11
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module OxTenderAbstract
6
+ # Configuration for the library
7
+ class Configuration
8
+ attr_accessor :token, :timeout_open, :timeout_read, :ssl_verify
9
+ attr_writer :wsdl_url, :logger
10
+
11
+ def initialize
12
+ @token = nil
13
+ @timeout_open = 30
14
+ @timeout_read = 120
15
+ @ssl_verify = false
16
+ @wsdl_url = nil # Will be set later
17
+ @logger = nil # Will be set later
18
+ end
19
+
20
+ def wsdl_url
21
+ @wsdl_url ||= DocumentTypes::API_CONFIG[:wsdl]
22
+ end
23
+
24
+ def logger
25
+ @logger ||= Logger.new($stdout).tap do |log|
26
+ log.level = Logger::INFO
27
+ log.formatter = proc do |severity, datetime, progname, msg|
28
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
29
+ end
30
+ end
31
+ end
32
+
33
+ def valid?
34
+ !token.nil? && !token.empty?
35
+ end
36
+
37
+ def token_from_file(file_path)
38
+ return nil unless File.exist?(file_path)
39
+
40
+ content = File.read(file_path).strip
41
+ content.empty? ? nil : content
42
+ end
43
+ end
44
+
45
+ class << self
46
+ attr_writer :configuration
47
+
48
+ def configuration
49
+ @configuration ||= Configuration.new
50
+ end
51
+
52
+ def configure
53
+ yield(configuration)
54
+ end
55
+
56
+ def reset_configuration!
57
+ @configuration = Configuration.new
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OxTenderAbstract
4
+ # Document types and constants for Zakupki API
5
+ module DocumentTypes
6
+ # Supported subsystem types
7
+ SUBSYSTEM_TYPES = %w[
8
+ PRIZ RPEC RPGZ RJ RDI BTK RPKLKP RPNZ RGK EA UR REC RPP RVP RRK RRA
9
+ RNP RKPO PPRF615 RD615 LKOK OZ OD223 RD223 MSP223 IPVP223 TRU223
10
+ RJ223 RPP223 RPZ223 RI223 RZ223 OV223 TPOZ223 POZ223 RNP223 POM223 ZC
11
+ ].freeze
12
+
13
+ # Document types for 44-FZ (federal law)
14
+ DOCUMENT_TYPES_44FZ = %w[
15
+ TENDER_PLAN TENDER_TERMS CONTRACT_PLAN TENDER_PROTOCOL
16
+ CONTRACT_EXECUTION_REPORT TENDER_NOTICE TENDER_DOCUMENTATION
17
+ ].freeze
18
+
19
+ # Electronic notification types
20
+ ELECTRONIC_NOTIFICATION_TYPES = %w[
21
+ epNotificationEF2020 epNotificationEF epNotificationOK2020
22
+ epNotificationEP2020 epNotificationZK2020 epNotificationZP2020
23
+ epNotificationISM2020 fcsNotificationEF fcsNotificationOK
24
+ fcsNotificationEP fcsNotificationZK fcsNotificationZP
25
+ fcsNotificationISM fcsPlacement fcsPlacementResult
26
+ ].freeze
27
+
28
+ # Default settings
29
+ DEFAULT_SUBSYSTEM = 'PRIZ'
30
+ DEFAULT_DOCUMENT_TYPE = 'epNotificationEF2020'
31
+
32
+ # API configuration
33
+ API_CONFIG = {
34
+ wsdl: 'https://int44.zakupki.gov.ru/eis-integration/services/getDocsIP?wsdl',
35
+ timeout: {
36
+ open: 30,
37
+ read: 120
38
+ },
39
+ ssl_verify: false
40
+ }.freeze
41
+ end
42
+ end
@@ -0,0 +1,11 @@
1
+ module OxTenderAbstract
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace OxTenderAbstract # this is generally recommended
4
+
5
+ # no other special configuration needed. But maybe you want
6
+ # an initializer to be added to the app? Easy!
7
+ # initializer 'yourgem.boot_stuff_up' do
8
+ # OxTenderAbstract.boot_something_up!
9
+ # end
10
+ end
11
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OxTenderAbstract
4
+ # Base error class for the library
5
+ class Error < StandardError; end
6
+
7
+ # Configuration related errors
8
+ class ConfigurationError < Error; end
9
+
10
+ # Network related errors
11
+ class NetworkError < Error; end
12
+
13
+ # SOAP API related errors
14
+ class SoapError < Error; end
15
+
16
+ # XML parsing related errors
17
+ class ParseError < Error; end
18
+
19
+ # Archive processing related errors
20
+ class ArchiveError < Error; end
21
+
22
+ # Authentication related errors
23
+ class AuthenticationError < Error; end
24
+ end