an_post_return 0.2.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.
@@ -0,0 +1,116 @@
1
+ require "csv"
2
+ require "time"
3
+ require_relative "../errors"
4
+
5
+ module AnPostReturn
6
+ module SFTP
7
+ class TrackingParser
8
+ REQUIRED_FIELDS = %w[tracking_number status timestamp].freeze
9
+ TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S".freeze
10
+
11
+ # Parse tracking data from a CSV file
12
+ #
13
+ # @param file [File, Tempfile] CSV file to parse
14
+ # @return [Array<Hash>] Array of tracking data entries
15
+ def self.parse(file)
16
+ new.parse(file)
17
+ end
18
+
19
+ # Parse tracking data from a text file
20
+ #
21
+ # @param file_path [String] Path to the text file to parse
22
+ # @return [Hash] Hash containing header, data records, and footer information
23
+ # @raise [AnPostReturn::ParserError] if file is not found or empty
24
+ def parse(file_path)
25
+ validate_file!(file_path)
26
+
27
+ result = { header: nil, data: [], footer: nil }
28
+
29
+ File.foreach(file_path) do |line|
30
+ line = line.strip
31
+ next if line.empty?
32
+
33
+ # Determine delimiter and parse fields
34
+ delimiter = line.include?("+") ? "+" : ","
35
+ fields = CSV.parse_line(line, col_sep: delimiter, quote_char: '"')
36
+ record_type = fields[0]
37
+
38
+ case record_type
39
+ when "00"
40
+ result[:header] = parse_header(fields)
41
+ when "01"
42
+ result[:data] << parse_data_record(fields)
43
+ when "99"
44
+ result[:footer] = parse_footer(fields)
45
+ else
46
+ next # Skip unknown record types
47
+ end
48
+ end
49
+
50
+ result
51
+ rescue CSV::MalformedCSVError => e
52
+ raise ParserError, "Invalid file format: #{e.message}"
53
+ rescue => e
54
+ raise ParserError, "Error parsing tracking data: #{e.message}"
55
+ end
56
+
57
+ private
58
+
59
+ def validate_file!(file_path)
60
+ raise ParserError, "File not found: #{file_path}" unless File.exist?(file_path)
61
+ raise ParserError, "Empty file: #{file_path}" if File.zero?(file_path)
62
+ end
63
+
64
+ def parse_header(fields)
65
+ {
66
+ record_type: fields[0],
67
+ file_id: fields[1],
68
+ timestamp: format_timestamp(fields[2]),
69
+ record_count: fields[3].to_i,
70
+ }
71
+ end
72
+
73
+ def parse_data_record(fields)
74
+ {
75
+ record_type: fields[0],
76
+ service: fields[1],
77
+ tracking_number: fields[2],
78
+ country: fields[3],
79
+ combined_id: fields[4],
80
+ status: fields[5],
81
+ timestamp: format_timestamp(fields[6]),
82
+ location: fields[7],
83
+ notes: fields[8],
84
+ additional_info: fields[9],
85
+ outcome: fields[10],
86
+ recipient: fields[11],
87
+ extra1: fields[12],
88
+ extra2: fields[13],
89
+ }.compact
90
+ end
91
+
92
+ def parse_footer(fields)
93
+ { record_type: fields[0], record_count: fields[1].to_i }
94
+ end
95
+
96
+ def format_timestamp(timestamp)
97
+ return nil if timestamp.nil? || timestamp.empty?
98
+
99
+ if timestamp.length >= 14
100
+ year = timestamp[0..3]
101
+ month = timestamp[4..5]
102
+ day = timestamp[6..7]
103
+ hour = timestamp[8..9]
104
+ minute = timestamp[10..11]
105
+ second = timestamp[12..13]
106
+
107
+ Time.new(year.to_i, month.to_i, day.to_i, hour.to_i, minute.to_i, second.to_i)
108
+ else
109
+ Time.parse(timestamp)
110
+ end
111
+ rescue ArgumentError => e
112
+ nil # Return nil for invalid timestamps instead of raising error
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,97 @@
1
+ require_relative "sftp/client"
2
+ require_relative "sftp/tracking_parser"
3
+ require_relative "configuration"
4
+
5
+ module AnPostReturn
6
+ class Tracker
7
+ # Initialize a new Tracking resource
8
+ def initialize
9
+ @config = AnPostReturn.configuration
10
+ raise ArgumentError, "SFTP configuration is not set" unless @config.sftp_configured?
11
+ end
12
+
13
+ # Track files with a specific account number
14
+ #
15
+ # @param account_number [String] The account number to track
16
+ # @param last [Integer] The number of files to track
17
+ # @yieldparam data [Hash] Parsed tracking data containing :header, :data, and :footer
18
+ # @return [void]
19
+ # @raise [AnPostReturn::SFTP::ConnectionError] if SFTP connection fails
20
+ # @raise [AnPostReturn::SFTP::FileError] if file operations fail
21
+ def track_with_account_number(account_number, last: 0, &block)
22
+ # pad the account number with leading zeros to 8 digits
23
+ account_number = account_number.to_s.rjust(8, "0")
24
+ with_sftp_client do |sftp_client|
25
+ file =
26
+ if last.zero?
27
+ sftp_client.list_files(@config.sftp_config[:remote_path], "cdt#{account_number}*").first
28
+ else
29
+ sftp_client.list_files(@config.sftp_config[:remote_path], "cdt#{account_number}*")[-(last + 1)]
30
+ end
31
+ track_from(file.name, sftp_client, &block) if file
32
+ end
33
+ end
34
+
35
+ # Get tracking data from a file, incrementing file number if needed
36
+ #
37
+ # @param last_filename [String, nil] Base last filename processed (e.g. "cdt0370132115864.txt")
38
+ # @param existing_sftp_client [SFTP::Client, nil] Existing SFTP client to use
39
+ # @yieldparam data [Hash] Parsed tracking data containing :header, :data, and :footer
40
+ # @return [void]
41
+ # @raise [AnPostReturn::SFTP::ConnectionError] if SFTP connection fails
42
+ # @raise [AnPostReturn::SFTP::FileError] if file operations fail
43
+ # @raise [AnPostReturn::ParserError] if parsing fails
44
+ def track_from(last_filename, existing_sftp_client = nil, &block)
45
+ return unless block_given?
46
+
47
+ with_sftp_client(existing_sftp_client) do |client|
48
+ # example file name: CDT99999999SSSSS.txt
49
+ # Where:
50
+ # • CDT is to prefix each file (Customer Data Tracking).
51
+ # • 99999999 is the An Post Customer Account Number.
52
+ # • SSSSS is a sequence number starting at 1 and incrementing by 1 for every file sent, with leading zeros.
53
+ # • .txt is the standard file extension.
54
+ #
55
+ # extract the customer account number, sequence number and extension
56
+ customer_account_number = last_filename.match(/^cdt(\d+)([0-9]{5})\.txt$/)[1]
57
+ sequence_number = last_filename.match(/^cdt(\d+)([0-9]{5})\.txt$/)[2]
58
+ extension = ".txt"
59
+
60
+ while true
61
+ # increment the sequence number
62
+ sequence_number = sequence_number.to_i + 1
63
+ # format the new filename
64
+ next_filename = "cdt#{customer_account_number}#{sequence_number.to_s.rjust(5, "0")}#{extension}"
65
+
66
+ client.read_file(next_filename) do |tracking_file|
67
+ data = SFTP::TrackingParser.parse(tracking_file)
68
+ yield next_filename, data
69
+ end
70
+ end
71
+ end
72
+ rescue SFTP::FileNotFoundError
73
+ # If file not found, we're done
74
+ return
75
+ end
76
+
77
+ private
78
+
79
+ def with_sftp_client(existing_sftp_client = nil)
80
+ if existing_sftp_client
81
+ client = existing_sftp_client
82
+ else
83
+ client =
84
+ SFTP::Client.new(
85
+ @config.sftp_config[:host],
86
+ @config.sftp_config[:username],
87
+ @config.sftp_config[:password],
88
+ @config.proxy_config,
89
+ )
90
+ client.connect
91
+ end
92
+ yield client
93
+ ensure
94
+ client&.disconnect unless existing_sftp_client
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnPostReturn
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "net/sftp"
6
+
7
+ require_relative "an_post_return/version"
8
+ require_relative "an_post_return/errors"
9
+
10
+ module AnPostReturn
11
+ class << self
12
+ attr_writer :configuration
13
+
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ def configure
19
+ yield(configuration)
20
+ end
21
+ end
22
+
23
+ # Autoload core classes
24
+ autoload :Configuration, "an_post_return/configuration"
25
+ autoload :Client, "an_post_return/client"
26
+
27
+ # Autoload object classes
28
+ autoload :Base, "an_post_return/objects/base"
29
+ autoload :ReturnLabel, "an_post_return/objects/return_label"
30
+
31
+ # Autoload tracker
32
+ autoload :Tracker, "an_post_return/tracker"
33
+
34
+ # Autoload resource classes
35
+ module Resources
36
+ autoload :Base, "an_post_return/resources/base"
37
+ autoload :ReturnLabelResource, "an_post_return/resources/return_label_resource"
38
+ end
39
+
40
+ # Autoload SFTP related classes
41
+ module SFTP
42
+ autoload :Client, "an_post_return/sftp/client"
43
+ autoload :Report, "an_post_return/sftp/report"
44
+ end
45
+ end
@@ -0,0 +1,47 @@
1
+ <context>
2
+ # Overview
3
+ [Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.]
4
+
5
+ # Core Features
6
+ [List and describe the main features of your product. For each feature, include:
7
+ - What it does
8
+ - Why it's important
9
+ - How it works at a high level]
10
+
11
+ # User Experience
12
+ [Describe the user journey and experience. Include:
13
+ - User personas
14
+ - Key user flows
15
+ - UI/UX considerations]
16
+ </context>
17
+ <PRD>
18
+ # Technical Architecture
19
+ [Outline the technical implementation details:
20
+ - System components
21
+ - Data models
22
+ - APIs and integrations
23
+ - Infrastructure requirements]
24
+
25
+ # Development Roadmap
26
+ [Break down the development process into phases:
27
+ - MVP requirements
28
+ - Future enhancements
29
+ - Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks]
30
+
31
+ # Logical Dependency Chain
32
+ [Define the logical order of development:
33
+ - Which features need to be built first (foundation)
34
+ - Getting as quickly as possible to something usable/visible front end that works
35
+ - Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches]
36
+
37
+ # Risks and Mitigations
38
+ [Identify potential risks and how they'll be addressed:
39
+ - Technical challenges
40
+ - Figuring out the MVP that we can build upon
41
+ - Resource constraints]
42
+
43
+ # Appendix
44
+ [Include any additional information:
45
+ - Research findings
46
+ - Technical specifications]
47
+ </PRD>
data/scripts/prd.txt ADDED
@@ -0,0 +1,147 @@
1
+ # AnPost API Ruby Gem PRD
2
+
3
+ ## Overview
4
+ The AnPost API Ruby gem provides a simple interface to interact with An Post's API services for return label creation and tracking information retrieval. The gem should support both production and test environments.
5
+
6
+ ## Core Requirements
7
+
8
+ ### Configuration
9
+ - Simple configuration system with:
10
+ - test/production environment toggle
11
+ - HTTP proxy configuration (optional):
12
+ - proxy_host: Host of the proxy server
13
+ - proxy_port: Port number
14
+ - proxy_username: Optional proxy authentication username
15
+ - proxy_password: Optional proxy authentication password
16
+ - Base URL determined by environment:
17
+ - Test: https://apim-anpost-mailslabels-nonprod.dev-anpost.com/returnsapi-q/v2/
18
+ - Production: https://apim-anpost-mailslabels.anpost.com/returnsapi/v2/
19
+
20
+ ### API Client
21
+ - Clean, minimal HTTP client implementation using Faraday
22
+ - Default JSON headers
23
+ - Subscription key passed per-request, not in configuration
24
+ - Proper error handling for API responses
25
+ - Support for HTTP proxy configuration from global settings
26
+
27
+ ### Resources
28
+ 1. Return Label Resource
29
+ - Create return labels with subscription key authentication
30
+ - Handle all necessary parameters for label creation
31
+ - Support JSON request/response formats
32
+
33
+ 2. Tracking Resource
34
+ - Implement SFTP client based on existing implementation in sftp_test.rb:
35
+ - Support proxy configuration from global settings
36
+ - Connection management (connect/disconnect)
37
+ - File operations (read_file, list_files)
38
+ - Error handling for SFTP operations
39
+
40
+ - Implement tracking parser based on existing implementation:
41
+ - Parse tracking file format (header, data records, footer)
42
+ - Support both '+' and ',' delimiters
43
+ - Handle quoted fields
44
+ - Structured data output with proper field mapping
45
+
46
+ - Features:
47
+ - List available tracking files with glob pattern support
48
+ - Download and parse tracking files
49
+ - Stream processing for large files
50
+ - Proper cleanup of temporary files
51
+ - Support for incremental file processing
52
+ - Status tracking and aggregation
53
+
54
+ ## Technical Requirements
55
+ - Ruby >= 3.0.0
56
+ - Dependencies:
57
+ - faraday for HTTP requests
58
+ - net-sftp for SFTP operations
59
+ - net-ssh-http-proxy for SFTP proxy support
60
+ - json for data handling
61
+ - csv for tracking file parsing
62
+ - Proper error classes and handling
63
+ - Comprehensive test coverage
64
+ - Standard Ruby code style
65
+
66
+ ## Implementation Notes
67
+ - Keep configuration minimal and focused
68
+ - Move authentication to request level
69
+ - Support environment-based URL switching
70
+ - Handle errors gracefully with descriptive messages
71
+ - Follow Ruby gem best practices
72
+ - Reuse existing SFTP and parsing code from sftp_test.rb
73
+ - Ensure proper resource cleanup
74
+ - Support proxy configuration across all components
75
+
76
+ ## SFTP Implementation Details
77
+ 1. SFTP Client Class:
78
+ ```ruby
79
+ module AnpostAPI
80
+ module SFTP
81
+ class Client
82
+ def initialize(host:, username:, password:, config: AnpostAPI.configuration)
83
+ # Initialize with credentials and use proxy from global config if available
84
+ end
85
+
86
+ def connect
87
+ # Establish SFTP connection with proxy support
88
+ end
89
+
90
+ def disconnect
91
+ # Clean disconnect
92
+ end
93
+
94
+ def read_file(remote_path, &block)
95
+ # Download and process file
96
+ end
97
+
98
+ def list_files(remote_path, glob_pattern = nil)
99
+ # List available files
100
+ end
101
+ end
102
+ end
103
+ end
104
+ ```
105
+
106
+ 2. Tracking Parser Class:
107
+ ```ruby
108
+ module AnpostAPI
109
+ module SFTP
110
+ class TrackingParser
111
+ def initialize(file_path)
112
+ # Initialize with file path
113
+ end
114
+
115
+ def parse
116
+ # Parse file contents
117
+ # Return structured data with header, records, and footer
118
+ end
119
+ end
120
+ end
121
+ end
122
+ ```
123
+
124
+ 3. Example Usage:
125
+ ```ruby
126
+ client = AnpostAPI::Client.new
127
+
128
+ # Configure proxy if needed
129
+ AnpostAPI.configure do |config|
130
+ config.proxy_host = "proxy.example.com"
131
+ config.proxy_port = 3128
132
+ config.proxy_username = "user"
133
+ config.proxy_password = "pass"
134
+ end
135
+
136
+ # Use tracking resource
137
+ client.tracking.get_updates(last_file: "cdt0379554008300.txt") do |filename, data|
138
+ # Process tracking data
139
+ puts "Processing file: #{filename}"
140
+ puts "Data: #{data}"
141
+ end
142
+ ```
143
+
144
+ ## Implementation Guidelines
145
+
146
+ ### 1. Code Structure
147
+ ```
@@ -0,0 +1,185 @@
1
+ require "net/sftp"
2
+ require "pry"
3
+ require "uri"
4
+ require "net/ssh/proxy/http"
5
+ require "x25519"
6
+ require "tempfile"
7
+ require "csv"
8
+
9
+ class SFTPClient
10
+ def initialize(host, user, password, http_proxy_config = nil)
11
+ @host = host
12
+ @user = user
13
+ @password = password
14
+ @http_proxy_config = http_proxy_config
15
+ end
16
+
17
+ def connect
18
+ sftp_client.connect!
19
+ rescue Net::SSH::Exception => e
20
+ puts "Failed to connect to #{@host}"
21
+ puts e.message
22
+ end
23
+
24
+ def disconnect
25
+ sftp_client.close_channel
26
+ ssh_session.close
27
+ end
28
+
29
+ def read_file(remote_path, &block)
30
+ temp_file = Tempfile.new(["sftp", File.extname(remote_path)])
31
+ begin
32
+ @sftp_client.download!(remote_path, temp_file.path)
33
+ block_given? ? block.call(temp_file) : temp_file
34
+ rescue Net::SFTP::StatusException => e
35
+ puts "Failed to download #{remote_path}: #{e.message}"
36
+ nil
37
+ ensure
38
+ temp_file.close
39
+ temp_file.unlink
40
+ end
41
+ end
42
+
43
+ def list_files(remote_path, glob_pattern = nil)
44
+ if glob_pattern
45
+ @sftp_client.dir.glob(remote_path, glob_pattern).each { |entry| puts entry.longname }
46
+ else
47
+ @sftp_client.dir.foreach(remote_path) { |entry| puts entry.longname }
48
+ end
49
+ end
50
+
51
+ def sftp_client
52
+ @sftp_client ||= Net::SFTP::Session.new(ssh_session)
53
+ end
54
+
55
+ private
56
+
57
+ def ssh_session
58
+ ssh_options = { password: @password, auth_methods: ["password"] }
59
+
60
+ # Add proxy configuration if provided
61
+ if @http_proxy_config
62
+ ssh_options[:proxy] = Net::SSH::Proxy::HTTP.new(
63
+ @http_proxy_config[:host],
64
+ @http_proxy_config[:port],
65
+ user: @http_proxy_config[:username],
66
+ password: @http_proxy_config[:password],
67
+ )
68
+ end
69
+
70
+ @ssh_session ||= Net::SSH.start(@host, @user, ssh_options)
71
+ end
72
+ end
73
+
74
+ class TrackingDataParser
75
+ def initialize(file_path)
76
+ @file_path = file_path
77
+ end
78
+
79
+ def parse
80
+ result = { header: nil, data: [], footer: nil }
81
+
82
+ File.foreach(@file_path) do |line|
83
+ line = line.strip
84
+ next if line.empty?
85
+
86
+ # Determine delimiter and parse with CSV to handle quoted fields
87
+ delimiter = line.include?("+") ? "+" : ","
88
+ fields = CSV.parse_line(line, col_sep: delimiter, quote_char: '"')
89
+ record_type = fields[0]
90
+
91
+ case record_type
92
+ when "00"
93
+ result[:header] = parse_header(fields)
94
+ when "01"
95
+ result[:data] << parse_data_record(fields)
96
+ when "99"
97
+ result[:footer] = parse_footer(fields)
98
+ else
99
+ puts "Warning: Unknown record type: #{record_type}"
100
+ end
101
+ end
102
+
103
+ result
104
+ end
105
+
106
+ private
107
+
108
+ def parse_header(fields)
109
+ { record_type: fields[0], file_id: fields[1], timestamp: fields[2], record_count: fields[3].to_i }
110
+ end
111
+
112
+ def parse_data_record(fields)
113
+ {
114
+ record_type: fields[0],
115
+ service: fields[1],
116
+ tracking_number: fields[2],
117
+ country: fields[3],
118
+ combined_id: fields[4],
119
+ status: fields[5],
120
+ timestamp: fields[6],
121
+ location: fields[7],
122
+ notes: fields[8],
123
+ additional_info: fields[9],
124
+ outcome: fields[10],
125
+ recipient: fields[11],
126
+ extra1: fields[12],
127
+ extra2: fields[13],
128
+ }
129
+ end
130
+
131
+ def parse_footer(fields)
132
+ { record_type: fields[0], record_count: fields[1].to_i }
133
+ end
134
+
135
+ def format_timestamp(timestamp)
136
+ return nil if timestamp.nil?
137
+
138
+ if timestamp.length >= 14
139
+ year = timestamp[0..3]
140
+ month = timestamp[4..5]
141
+ day = timestamp[6..7]
142
+ hour = timestamp[8..9]
143
+ minute = timestamp[10..11]
144
+ second = timestamp[12..13]
145
+
146
+ "#{year}-#{month}-#{day} #{hour}:#{minute}:#{second}"
147
+ else
148
+ timestamp
149
+ end
150
+ end
151
+ end
152
+
153
+ # Example proxy configuration
154
+ http_proxy_config = {
155
+ host: "sgp-forward-proxy.postco.co",
156
+ port: 3128,
157
+ username: "proxyuser", # Optional for HTTP
158
+ password: "VG17nL@qYgAFts", # Optional for HTTP
159
+ }
160
+
161
+ start_time = Time.now
162
+ # With proxy
163
+ sftp = SFTPClient.new("anpost.moveitcloud.eu", "ctuserpostco", "nUVG<akG[4@vc}^o", http_proxy_config)
164
+ sftp.connect
165
+ end_time = Time.now
166
+ puts "Time taken to connect: #{end_time - start_time} seconds"
167
+
168
+ # Rest of your code remains the same
169
+ # sftp.list_files("/home/ctuserpostco", "cdt03795540083[4-9]*.txt")
170
+
171
+ start = 379_554_008_300
172
+ statuses = Set.new
173
+ # increment by 1 for 10 times
174
+ 100.times do
175
+ file_name = "cdt0#{start += 1}.txt"
176
+ sftp.read_file("#{file_name}") do |temp_file|
177
+ parser = TrackingDataParser.new(temp_file.path)
178
+ data = parser.parse
179
+ data[:data].each { |record| statuses.add(record[:status]) }
180
+ end
181
+ end
182
+
183
+ puts "Statuses: #{statuses.size}"
184
+
185
+ sftp.disconnect