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.
- checksums.yaml +7 -0
- data/.cursor/rules/010-project-structure.mdc +11 -0
- data/.cursor/rules/998-clean-code.mdc +62 -0
- data/.cursor/rules/999-mdc-format.mdc +132 -0
- data/.cursor/rules/api-integration.mdc +63 -0
- data/.cursor/rules/project-structure.mdc +113 -0
- data/.cursor/rules/ruby-conventions.mdc +121 -0
- data/.cursor/rules/testing-patterns.mdc +169 -0
- data/.rspec +3 -0
- data/.rspec_status +73 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +3 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE +21 -0
- data/README.md +297 -0
- data/Rakefile +12 -0
- data/lib/ox-tender-abstract.rb +39 -0
- data/lib/oxtenderabstract/archive_processor.rb +175 -0
- data/lib/oxtenderabstract/client.rb +347 -0
- data/lib/oxtenderabstract/compatibility.rb +11 -0
- data/lib/oxtenderabstract/configuration.rb +60 -0
- data/lib/oxtenderabstract/document_types.rb +42 -0
- data/lib/oxtenderabstract/engine.rb +11 -0
- data/lib/oxtenderabstract/errors.rb +24 -0
- data/lib/oxtenderabstract/logger.rb +42 -0
- data/lib/oxtenderabstract/result.rb +31 -0
- data/lib/oxtenderabstract/version.rb +5 -0
- data/lib/oxtenderabstract/xml_parser.rb +529 -0
- data/lib/ruby/ox-tender-abstract.rb +2 -0
- metadata +229 -0
@@ -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
|