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.
@@ -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
- ELECTRONIC_NOTIFICATION_TYPES = %w[
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
- # Network related errors
11
- class NetworkError < Error; end
10
+ # API related errors
11
+ class ApiError < Error; end
12
12
 
13
- # SOAP API related errors
14
- class SoapError < Error; end
13
+ # Archive processing errors
14
+ class ArchiveError < Error; end
15
15
 
16
- # XML parsing related errors
16
+ # XML parsing errors
17
17
  class ParseError < Error; end
18
18
 
19
- # Archive processing related errors
20
- class ArchiveError < Error; end
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
- # Authentication related errors
23
- class AuthenticationError < Error; end
32
+ def can_retry_at
33
+ @blocked_until
34
+ end
35
+ end
24
36
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OxTenderAbstract
4
- VERSION = '0.9.1'
4
+ VERSION = '0.9.3'
5
5
  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 = text.gsub(/[^\d\s.,]/, '').strip
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 - use more defensive approach
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
- # Extract total sum from purchaseObjectsInfo
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 purchase_objects.empty? && total_sum.nil?
531
+ return {} if all_objects.empty? && total_sum.nil?
510
532
 
511
533
  {
512
- objects: purchase_objects,
513
- objects_count: purchase_objects.size,
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: extract_text_from_node(object_node, './/ns4:name | .//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 (simplified - just count and basic info)
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: characteristics_nodes.first(5).map do |char_node|
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: KTRU name > OKPD2 name > name field
598
- product_name = nil
599
- product_name = if object_data[:ktru] && object_data[:ktru][:name] && !object_data[:ktru][:name].empty?
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
- object_data[:name]
645
+ 'Unknown product'
605
646
  end
606
647
 
607
648
  object_data[:product_name] = product_name
608
649
 
609
- # Add field description indicating what the 'name' field actually contains
610
- object_data[:name_type] = determine_name_type(object_data[:name])
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ox-tender-abstract
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - smolev