ox-tender-abstract 0.9.1 → 0.9.3
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 +4 -4
- data/.rspec_status +18 -18
- data/CHANGELOG.md +11 -0
- data/README.md +35 -0
- data/lib/ox-tender-abstract.rb +154 -21
- data/lib/oxtenderabstract/archive_processor.rb +192 -76
- data/lib/oxtenderabstract/client.rb +170 -20
- data/lib/oxtenderabstract/configuration.rb +5 -1
- data/lib/oxtenderabstract/document_types.rb +72 -2
- data/lib/oxtenderabstract/errors.rb +21 -9
- data/lib/oxtenderabstract/version.rb +1 -1
- data/lib/oxtenderabstract/xml_parser.rb +164 -23
- metadata +1 -1
@@ -16,8 +16,8 @@ module OxTenderAbstract
|
|
16
16
|
CONTRACT_EXECUTION_REPORT TENDER_NOTICE TENDER_DOCUMENTATION
|
17
17
|
].freeze
|
18
18
|
|
19
|
-
# Electronic notification types
|
20
|
-
|
19
|
+
# Electronic notification types for 44-FZ
|
20
|
+
ELECTRONIC_NOTIFICATION_TYPES_44FZ = %w[
|
21
21
|
epNotificationEF2020 epNotificationEF epNotificationOK2020
|
22
22
|
epNotificationEP2020 epNotificationZK2020 epNotificationZP2020
|
23
23
|
epNotificationISM2020 fcsNotificationEF fcsNotificationOK
|
@@ -25,10 +25,80 @@ module OxTenderAbstract
|
|
25
25
|
fcsNotificationISM fcsPlacement fcsPlacementResult
|
26
26
|
].freeze
|
27
27
|
|
28
|
+
# Electronic notification types for 223-FZ
|
29
|
+
ELECTRONIC_NOTIFICATION_TYPES_223FZ = %w[
|
30
|
+
epNotification223 notification223 purchaseNotice223
|
31
|
+
purchaseNoticeEA223 purchaseNoticeZK223 purchaseNoticeZP223
|
32
|
+
purchaseNoticeOK223 purchaseNoticeIS223 contractNotice223
|
33
|
+
contractExecutionNotice223 purchasePlan223
|
34
|
+
].freeze
|
35
|
+
|
36
|
+
# Electronic notification types for regional and municipal
|
37
|
+
ELECTRONIC_NOTIFICATION_TYPES_REGIONAL = %w[
|
38
|
+
epNotificationRP epNotificationRPGZ notificationRP
|
39
|
+
notificationRPGZ purchaseNoticeRP purchaseNoticeRPGZ
|
40
|
+
contractNoticeRP contractNoticeRPGZ
|
41
|
+
].freeze
|
42
|
+
|
43
|
+
# All supported electronic notification types
|
44
|
+
ELECTRONIC_NOTIFICATION_TYPES = (
|
45
|
+
ELECTRONIC_NOTIFICATION_TYPES_44FZ +
|
46
|
+
ELECTRONIC_NOTIFICATION_TYPES_223FZ +
|
47
|
+
ELECTRONIC_NOTIFICATION_TYPES_REGIONAL
|
48
|
+
).freeze
|
49
|
+
|
28
50
|
# Default settings
|
29
51
|
DEFAULT_SUBSYSTEM = 'PRIZ'
|
30
52
|
DEFAULT_DOCUMENT_TYPE = 'epNotificationEF2020'
|
31
53
|
|
54
|
+
# Subsystem descriptions
|
55
|
+
SUBSYSTEM_DESCRIPTIONS = {
|
56
|
+
'PRIZ' => '44-ФЗ - Основные закупки федеральных органов',
|
57
|
+
'OD223' => '223-ФЗ - Закупки отдельных видов юридических лиц',
|
58
|
+
'RD223' => '223-ФЗ - Реестр договоров',
|
59
|
+
'RPEC' => 'Закупки субъектов РФ',
|
60
|
+
'RPGZ' => 'Муниципальные закупки',
|
61
|
+
'RGK' => 'Закупки государственных корпораций',
|
62
|
+
'BTK' => 'Закупки бюджетных, автономных учреждений',
|
63
|
+
'UR' => 'Закупки субъектов естественных монополий',
|
64
|
+
'RJ' => 'Закупки для нужд судебной системы',
|
65
|
+
'RDI' => 'Закупки для нужд дошкольных образовательных учреждений',
|
66
|
+
'RPKLKP' => 'Закупки для нужд подведомственных Калининградской области',
|
67
|
+
'RPNZ' => 'Закупки для нужд образовательных учреждений НЗО',
|
68
|
+
'EA' => 'Электронные аукционы',
|
69
|
+
'REC' => 'Реестр недобросовестных поставщиков',
|
70
|
+
'RPP' => 'Реестр поставщиков',
|
71
|
+
'RVP' => 'Реестр внутренних поставщиков',
|
72
|
+
'RRK' => 'Реестр результатов контроля',
|
73
|
+
'RRA' => 'Реестр результатов аудита',
|
74
|
+
'RNP' => 'Реестр нарушений при проведении закупок',
|
75
|
+
'RKPO' => 'Реестр контрольно-проверочных организаций'
|
76
|
+
}.freeze
|
77
|
+
|
78
|
+
# Get appropriate document types for subsystem
|
79
|
+
def self.document_types_for_subsystem(subsystem_type)
|
80
|
+
case subsystem_type
|
81
|
+
when 'PRIZ', 'RPEC', 'RPGZ', 'RGK', 'BTK', 'UR', 'RJ', 'RDI'
|
82
|
+
ELECTRONIC_NOTIFICATION_TYPES_44FZ
|
83
|
+
when 'OD223', 'RD223'
|
84
|
+
ELECTRONIC_NOTIFICATION_TYPES_223FZ + ELECTRONIC_NOTIFICATION_TYPES_44FZ
|
85
|
+
when /RP/
|
86
|
+
ELECTRONIC_NOTIFICATION_TYPES_REGIONAL + ELECTRONIC_NOTIFICATION_TYPES_44FZ
|
87
|
+
else
|
88
|
+
ELECTRONIC_NOTIFICATION_TYPES_44FZ
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
# Check if subsystem supports document type
|
93
|
+
def self.subsystem_supports_document_type?(subsystem_type, document_type)
|
94
|
+
document_types_for_subsystem(subsystem_type).include?(document_type)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Get description for subsystem
|
98
|
+
def self.description_for_subsystem(subsystem_type)
|
99
|
+
SUBSYSTEM_DESCRIPTIONS[subsystem_type] || "Подсистема #{subsystem_type}"
|
100
|
+
end
|
101
|
+
|
32
102
|
# API configuration
|
33
103
|
API_CONFIG = {
|
34
104
|
wsdl: 'https://int44.zakupki.gov.ru/eis-integration/services/getDocsIP?wsdl',
|
@@ -7,18 +7,30 @@ module OxTenderAbstract
|
|
7
7
|
# Configuration related errors
|
8
8
|
class ConfigurationError < Error; end
|
9
9
|
|
10
|
-
#
|
11
|
-
class
|
10
|
+
# API related errors
|
11
|
+
class ApiError < Error; end
|
12
12
|
|
13
|
-
#
|
14
|
-
class
|
13
|
+
# Archive processing errors
|
14
|
+
class ArchiveError < Error; end
|
15
15
|
|
16
|
-
# XML parsing
|
16
|
+
# XML parsing errors
|
17
17
|
class ParseError < Error; end
|
18
18
|
|
19
|
-
#
|
20
|
-
class
|
19
|
+
# Network related errors
|
20
|
+
class NetworkError < Error; end
|
21
|
+
|
22
|
+
# Archive download blocked error (10 minute block)
|
23
|
+
class ArchiveBlockedError < ArchiveError
|
24
|
+
attr_reader :blocked_until, :retry_after_seconds
|
25
|
+
|
26
|
+
def initialize(message = 'Archive download blocked', retry_after_seconds = 600)
|
27
|
+
super(message)
|
28
|
+
@retry_after_seconds = retry_after_seconds
|
29
|
+
@blocked_until = Time.now + retry_after_seconds
|
30
|
+
end
|
21
31
|
|
22
|
-
|
23
|
-
|
32
|
+
def can_retry_at
|
33
|
+
@blocked_until
|
34
|
+
end
|
35
|
+
end
|
24
36
|
end
|
@@ -408,8 +408,11 @@ module OxTenderAbstract
|
|
408
408
|
def extract_price_from_text(text)
|
409
409
|
return nil if text.nil? || text.empty?
|
410
410
|
|
411
|
+
# Remove currency symbols and text like 'руб.', 'рублей' etc.
|
412
|
+
cleaned = text.gsub(/[а-яё]+\.?/i, '').strip
|
413
|
+
|
411
414
|
# Remove any non-digit characters except decimal separator and spaces
|
412
|
-
cleaned =
|
415
|
+
cleaned = cleaned.gsub(/[^\d\s.,]/, '').strip
|
413
416
|
return nil if cleaned.empty?
|
414
417
|
|
415
418
|
# Remove spaces (used as thousand separators in Russian format)
|
@@ -483,36 +486,57 @@ module OxTenderAbstract
|
|
483
486
|
total_sum = nil
|
484
487
|
|
485
488
|
begin
|
486
|
-
# Find purchase objects nodes -
|
489
|
+
# Find purchase objects nodes - including drug and non-drug objects
|
490
|
+
# Regular purchase objects
|
487
491
|
purchase_object_nodes = doc.xpath(
|
488
492
|
'//ns5:purchaseObjectsInfo//ns4:purchaseObject | //purchaseObjectsInfo//purchaseObject', namespaces
|
489
493
|
)
|
490
494
|
|
495
|
+
# Drug purchase objects (лекарственные препараты)
|
496
|
+
drug_object_nodes = doc.xpath(
|
497
|
+
'//ns5:drugPurchaseObjectsInfo//ns4:drugPurchaseObjectInfo | //drugPurchaseObjectsInfo//drugPurchaseObjectInfo', namespaces
|
498
|
+
)
|
499
|
+
|
500
|
+
# Process regular purchase objects
|
491
501
|
purchase_objects = purchase_object_nodes.map do |object_node|
|
492
502
|
extract_purchase_object_data(object_node, namespaces)
|
493
503
|
end.compact
|
494
504
|
|
495
|
-
#
|
505
|
+
# Process drug purchase objects
|
506
|
+
drug_objects = drug_object_nodes.map do |drug_node|
|
507
|
+
extract_drug_purchase_object_data(drug_node, namespaces)
|
508
|
+
end.compact
|
509
|
+
|
510
|
+
# Combine all objects
|
511
|
+
all_objects = purchase_objects + drug_objects
|
512
|
+
|
513
|
+
# Extract total sum from various sources
|
496
514
|
total_sum = extract_price_from_text(find_text_with_namespaces(doc, [
|
497
515
|
'//ns5:purchaseObjectsInfo//ns4:totalSum',
|
498
516
|
'//purchaseObjectsInfo//totalSum',
|
499
517
|
'//ns5:notDrugPurchaseObjectsInfo/ns4:totalSum',
|
500
|
-
'//notDrugPurchaseObjectsInfo/totalSum'
|
518
|
+
'//notDrugPurchaseObjectsInfo/totalSum',
|
519
|
+
'//ns5:drugPurchaseObjectsInfo/ns4:total',
|
520
|
+
'//drugPurchaseObjectsInfo/total'
|
501
521
|
], namespaces))
|
502
522
|
|
503
523
|
# Extract quantity undefined flag
|
504
524
|
quantity_undefined = find_text_with_namespaces(doc, [
|
505
525
|
'//ns5:purchaseObjectsInfo//ns5:quantityUndefined',
|
506
|
-
'//purchaseObjectsInfo//quantityUndefined'
|
526
|
+
'//purchaseObjectsInfo//quantityUndefined',
|
527
|
+
'//ns5:drugPurchaseObjectsInfo//ns5:quantityUndefined',
|
528
|
+
'//drugPurchaseObjectsInfo//quantityUndefined'
|
507
529
|
], namespaces) == 'true'
|
508
530
|
|
509
|
-
return {} if
|
531
|
+
return {} if all_objects.empty? && total_sum.nil?
|
510
532
|
|
511
533
|
{
|
512
|
-
objects:
|
513
|
-
objects_count:
|
534
|
+
objects: all_objects,
|
535
|
+
objects_count: all_objects.size,
|
514
536
|
total_sum: total_sum,
|
515
|
-
quantity_undefined: quantity_undefined
|
537
|
+
quantity_undefined: quantity_undefined,
|
538
|
+
drug_objects_count: drug_objects.size,
|
539
|
+
regular_objects_count: purchase_objects.size
|
516
540
|
}.compact
|
517
541
|
rescue StandardError => e
|
518
542
|
log_debug "Error extracting purchase objects: #{e.message}"
|
@@ -522,10 +546,13 @@ module OxTenderAbstract
|
|
522
546
|
|
523
547
|
def extract_purchase_object_data(object_node, namespaces)
|
524
548
|
# Basic object information
|
549
|
+
# CRITICAL FIX: Extract name that's direct child of purchaseObject, not from characteristics
|
550
|
+
direct_name = object_node.xpath('./ns4:name | ./name', namespaces).first&.text&.strip
|
551
|
+
|
525
552
|
object_data = {
|
526
553
|
sid: extract_text_from_node(object_node, './/ns4:sid | .//sid'),
|
527
554
|
external_sid: extract_text_from_node(object_node, './/ns4:externalSid | .//externalSid'),
|
528
|
-
name:
|
555
|
+
name: direct_name,
|
529
556
|
price: extract_price_from_text(extract_text_from_node(object_node, './/ns4:price | .//price')),
|
530
557
|
quantity: extract_text_from_node(object_node, './/ns4:quantity/ns4:value | .//quantity/value')&.to_i,
|
531
558
|
sum: extract_price_from_text(extract_text_from_node(object_node, './/ns4:sum | .//sum')),
|
@@ -573,7 +600,7 @@ module OxTenderAbstract
|
|
573
600
|
}
|
574
601
|
end
|
575
602
|
|
576
|
-
# Extract characteristics (
|
603
|
+
# Extract characteristics (detailed extraction)
|
577
604
|
characteristics_nodes = object_node.xpath(
|
578
605
|
'.//ns4:characteristics//ns4:characteristicsUsingReferenceInfo | .//characteristics//characteristicsUsingReferenceInfo', namespaces
|
579
606
|
)
|
@@ -582,36 +609,150 @@ module OxTenderAbstract
|
|
582
609
|
)
|
583
610
|
|
584
611
|
if characteristics_nodes.any?
|
612
|
+
characteristics_details = characteristics_nodes.map do |char_node|
|
613
|
+
char_data = {
|
614
|
+
name: extract_text_from_node(char_node, './/ns4:name | .//name'),
|
615
|
+
type: extract_text_from_node(char_node, './/ns4:type | .//type')
|
616
|
+
}
|
617
|
+
|
618
|
+
# Extract values from text form characteristics
|
619
|
+
values_nodes = char_node.xpath('.//ns4:values/ns4:value | .//values/value', namespaces)
|
620
|
+
if values_nodes.any?
|
621
|
+
char_data[:values] = values_nodes.map do |value_node|
|
622
|
+
extract_text_from_node(value_node, './/ns4:qualityDescription | .//qualityDescription') ||
|
623
|
+
extract_text_from_node(value_node, './/ns4:textValue | .//textValue')
|
624
|
+
end.compact
|
625
|
+
end
|
626
|
+
|
627
|
+
char_data
|
628
|
+
end
|
629
|
+
|
585
630
|
object_data[:characteristics] = {
|
586
631
|
count: characteristics_nodes.size,
|
587
|
-
details:
|
588
|
-
{
|
589
|
-
name: extract_text_from_node(char_node, './/ns4:name | .//name'),
|
590
|
-
type: extract_text_from_node(char_node, './/ns4:type | .//type')
|
591
|
-
}
|
592
|
-
end
|
632
|
+
details: characteristics_details
|
593
633
|
}
|
594
634
|
end
|
595
635
|
|
596
636
|
# Determine the actual product name from available sources
|
597
|
-
# Priority:
|
598
|
-
product_name =
|
599
|
-
|
637
|
+
# Priority: Direct name field (now fixed) > KTRU name > OKPD2 name
|
638
|
+
product_name = if object_data[:name] && !object_data[:name].empty?
|
639
|
+
object_data[:name]
|
640
|
+
elsif object_data[:ktru] && object_data[:ktru][:name] && !object_data[:ktru][:name].empty?
|
600
641
|
object_data[:ktru][:name]
|
601
642
|
elsif object_data[:okpd2] && object_data[:okpd2][:name] && !object_data[:okpd2][:name].empty?
|
602
643
|
object_data[:okpd2][:name]
|
603
644
|
else
|
604
|
-
|
645
|
+
'Unknown product'
|
605
646
|
end
|
606
647
|
|
607
648
|
object_data[:product_name] = product_name
|
608
649
|
|
609
|
-
#
|
610
|
-
object_data[:name_type] =
|
650
|
+
# Now the name field should contain actual product names, not characteristics
|
651
|
+
object_data[:name_type] = 'product_name'
|
611
652
|
|
612
653
|
object_data.compact
|
613
654
|
end
|
614
655
|
|
656
|
+
def extract_drug_purchase_object_data(drug_node, namespaces)
|
657
|
+
# Extract data from drug purchase object info
|
658
|
+
drug_data = {
|
659
|
+
sid: extract_text_from_node(drug_node, './/ns4:sid | .//sid'),
|
660
|
+
external_sid: extract_text_from_node(drug_node, './/ns4:externalSid | .//externalSid'),
|
661
|
+
name: extract_text_from_node(drug_node, './/ns4:name | .//name'),
|
662
|
+
price: extract_price_from_text(extract_text_from_node(drug_node, './/ns4:price | .//price')),
|
663
|
+
quantity: extract_text_from_node(drug_node, './/ns4:quantity/ns4:value | .//quantity/value')&.to_i,
|
664
|
+
sum: extract_price_from_text(extract_text_from_node(drug_node, './/ns4:sum | .//sum')),
|
665
|
+
type: 'drug', # Mark as drug object
|
666
|
+
hierarchy_type: extract_text_from_node(drug_node, './/ns4:hierarchyType | .//hierarchyType')
|
667
|
+
}
|
668
|
+
|
669
|
+
# Extract INN (International Nonproprietary Name) for drugs
|
670
|
+
inn_node = drug_node.at_xpath('.//ns4:INN | .//INN', namespaces)
|
671
|
+
if inn_node
|
672
|
+
drug_data[:inn] = {
|
673
|
+
code: extract_text_from_node(inn_node, './/ns2:code | .//code'),
|
674
|
+
name: extract_text_from_node(inn_node, './/ns2:name | .//name')
|
675
|
+
}
|
676
|
+
end
|
677
|
+
|
678
|
+
# Extract dosage form information
|
679
|
+
dosage_form_node = drug_node.at_xpath('.//ns4:dosageForm | .//dosageForm', namespaces)
|
680
|
+
if dosage_form_node
|
681
|
+
drug_data[:dosage_form] = {
|
682
|
+
code: extract_text_from_node(dosage_form_node, './/ns2:code | .//code'),
|
683
|
+
name: extract_text_from_node(dosage_form_node, './/ns2:name | .//name')
|
684
|
+
}
|
685
|
+
end
|
686
|
+
|
687
|
+
# OKPD2 information for drugs
|
688
|
+
okpd2_node = drug_node.at_xpath('.//ns4:OKPD2 | .//OKPD2', namespaces)
|
689
|
+
if okpd2_node
|
690
|
+
drug_data[:okpd2] = {
|
691
|
+
code: extract_text_from_node(okpd2_node, './/ns2:OKPDCode | .//OKPDCode'),
|
692
|
+
name: extract_text_from_node(okpd2_node, './/ns2:OKPDName | .//OKPDName')
|
693
|
+
}
|
694
|
+
end
|
695
|
+
|
696
|
+
# OKEI information (units of measurement)
|
697
|
+
okei_node = drug_node.at_xpath('.//ns4:OKEI | .//OKEI', namespaces)
|
698
|
+
if okei_node
|
699
|
+
drug_data[:okei] = {
|
700
|
+
code: extract_text_from_node(okei_node, './/ns2:code | .//code'),
|
701
|
+
national_code: extract_text_from_node(okei_node, './/ns2:nationalCode | .//nationalCode'),
|
702
|
+
name: extract_text_from_node(okei_node, './/ns2:name | .//name')
|
703
|
+
}
|
704
|
+
end
|
705
|
+
|
706
|
+
# Extract characteristics for drugs
|
707
|
+
characteristics_nodes = drug_node.xpath(
|
708
|
+
'.//ns4:characteristics//ns4:characteristicsUsingReferenceInfo | .//characteristics//characteristicsUsingReferenceInfo', namespaces
|
709
|
+
)
|
710
|
+
characteristics_nodes += drug_node.xpath(
|
711
|
+
'.//ns4:characteristics//ns4:characteristicsUsingTextForm | .//characteristics//characteristicsUsingTextForm', namespaces
|
712
|
+
)
|
713
|
+
|
714
|
+
if characteristics_nodes.any?
|
715
|
+
characteristics_details = characteristics_nodes.map do |char_node|
|
716
|
+
char_data = {
|
717
|
+
name: extract_text_from_node(char_node, './/ns4:name | .//name'),
|
718
|
+
type: extract_text_from_node(char_node, './/ns4:type | .//type')
|
719
|
+
}
|
720
|
+
|
721
|
+
# Extract values from text form characteristics
|
722
|
+
values_nodes = char_node.xpath('.//ns4:values/ns4:value | .//values/value', namespaces)
|
723
|
+
if values_nodes.any?
|
724
|
+
char_data[:values] = values_nodes.map do |value_node|
|
725
|
+
extract_text_from_node(value_node, './/ns4:qualityDescription | .//qualityDescription') ||
|
726
|
+
extract_text_from_node(value_node, './/ns4:textValue | .//textValue')
|
727
|
+
end.compact
|
728
|
+
end
|
729
|
+
|
730
|
+
char_data
|
731
|
+
end
|
732
|
+
|
733
|
+
drug_data[:characteristics] = {
|
734
|
+
count: characteristics_nodes.size,
|
735
|
+
details: characteristics_details
|
736
|
+
}
|
737
|
+
end
|
738
|
+
|
739
|
+
# Determine the product name
|
740
|
+
product_name = if drug_data[:name] && !drug_data[:name].empty?
|
741
|
+
drug_data[:name]
|
742
|
+
elsif drug_data[:inn] && drug_data[:inn][:name] && !drug_data[:inn][:name].empty?
|
743
|
+
drug_data[:inn][:name]
|
744
|
+
elsif drug_data[:okpd2] && drug_data[:okpd2][:name] && !drug_data[:okpd2][:name].empty?
|
745
|
+
drug_data[:okpd2][:name]
|
746
|
+
else
|
747
|
+
'Unknown drug'
|
748
|
+
end
|
749
|
+
|
750
|
+
drug_data[:product_name] = product_name
|
751
|
+
drug_data[:name_type] = 'drug_name'
|
752
|
+
|
753
|
+
drug_data.compact
|
754
|
+
end
|
755
|
+
|
615
756
|
private
|
616
757
|
|
617
758
|
def determine_name_type(name)
|