spree_packeta 0.1.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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreePacketa
4
+ module Models
5
+ class ShippingMethod < Spree::ShippingMethod
6
+ def carrier
7
+ 'Packeta'
8
+ end
9
+
10
+ # Check if this shipping method is available for the given package
11
+ #
12
+ # @param package [Spree::Stock::Package] The package to check
13
+ # @return [Boolean] True if available
14
+ def available?(package, _display_on = nil)
15
+ super && packeta_available_for_package?(package)
16
+ end
17
+
18
+ # Build tracking URL for a shipment
19
+ #
20
+ # @param tracking_number [String] The Packeta packet ID
21
+ # @return [String] Tracking URL
22
+ def build_tracking_url(tracking_number)
23
+ "https://tracking.packeta.com/#{tracking_number}"
24
+ end
25
+
26
+ private
27
+
28
+ def packeta_available_for_package?(package)
29
+ # Check if package destination is supported
30
+ return false unless package_destination_supported?(package)
31
+
32
+ # Check if package weight is acceptable
33
+ return false unless package_weight_acceptable?(package)
34
+
35
+ true
36
+ end
37
+
38
+ def package_destination_supported?(package)
39
+ # Get the destination country
40
+ country_code = package.order.ship_address&.country&.iso
41
+
42
+ # For now, support Czech Republic and EU countries
43
+ # This can be expanded based on Packeta's coverage
44
+ supported_countries = %w[CZ SK PL HU RO AT DE]
45
+ supported_countries.include?(country_code)
46
+ end
47
+
48
+ def package_weight_acceptable?(package)
49
+ # Calculate total weight
50
+ total_weight = package.contents.sum { |item| item.variant.weight.to_f * item.quantity }
51
+
52
+ # Packeta typically has a maximum weight limit (e.g., 30kg)
53
+ # Adjust this based on your needs
54
+ max_weight = 30.0 # kg
55
+ total_weight <= max_weight
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreePacketa
4
+ module Services
5
+ class PacketCreator
6
+ attr_reader :shipment, :operations
7
+
8
+ def initialize(shipment, operations = nil)
9
+ @shipment = shipment
10
+ @operations = operations || Soap::Operations.new
11
+ end
12
+
13
+ # Create a Packeta packet from a Spree shipment
14
+ #
15
+ # @return [Hash] Packet details with :id, :barcode, :barcode_text
16
+ # @raise [ValidationError] If packet attributes are invalid
17
+ def create_packet
18
+ raise ValidationError, 'Shipment already has a Packeta packet' if shipment.packeta_packet_id.present?
19
+
20
+ # Extract attributes from shipment
21
+ attributes = build_packet_attributes
22
+
23
+ # Validate attributes first
24
+ operations.packet_attributes_valid?(attributes)
25
+
26
+ # Create the packet
27
+ result = operations.create_packet(attributes)
28
+
29
+ # Update shipment with packet details
30
+ update_shipment_with_packet(result)
31
+
32
+ result
33
+ rescue ValidationError => e
34
+ Rails.logger.error("Failed to create Packeta packet for shipment #{shipment.id}: #{e.message}")
35
+ raise
36
+ end
37
+
38
+ private
39
+
40
+ def build_packet_attributes
41
+ order = shipment.order
42
+ address = order.ship_address
43
+
44
+ {
45
+ number: order.number,
46
+ name: address.firstname,
47
+ surname: address.lastname,
48
+ company: address.company,
49
+ email: order.email,
50
+ phone: address.phone,
51
+ address_id: packeta_pickup_point_id,
52
+ value: order.item_total.to_f,
53
+ weight: calculate_weight,
54
+ cod: calculate_cod,
55
+ currency: order.currency,
56
+ eshop: SpreePacketa.configuration.eshop
57
+ }
58
+ end
59
+
60
+ def packeta_pickup_point_id
61
+ # Get pickup point from order metadata (set by frontend widget)
62
+ order.packeta_pickup_point_id ||
63
+ raise(ValidationError, 'No Packeta pickup point selected')
64
+ end
65
+
66
+ def calculate_weight
67
+ # Calculate total weight in kg
68
+ shipment.line_items.sum do |line_item|
69
+ (line_item.variant.weight || 0) * line_item.quantity
70
+ end
71
+ end
72
+
73
+ def calculate_cod
74
+ # Cash on delivery amount
75
+ # Only if payment method is COD
76
+ payment_method = shipment.order.payments.last&.payment_method
77
+ if payment_method&.type == 'Spree::PaymentMethod::COD'
78
+ shipment.order.total.to_f
79
+ else
80
+ 0
81
+ end
82
+ end
83
+
84
+ def update_shipment_with_packet(packet_details)
85
+ shipment.update!(
86
+ packeta_packet_id: packet_details[:id],
87
+ packeta_barcode: packet_details[:barcode],
88
+ packeta_tracking_url: build_tracking_url(packet_details[:id]),
89
+ tracking: packet_details[:id].to_s
90
+ )
91
+ end
92
+
93
+ def build_tracking_url(packet_id)
94
+ "https://tracking.packeta.com/#{packet_id}"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreePacketa
4
+ module Services
5
+ class Tracker
6
+ attr_reader :shipment, :operations
7
+
8
+ def initialize(shipment, operations = nil)
9
+ @shipment = shipment
10
+ @operations = operations || Soap::Operations.new
11
+ end
12
+
13
+ # Get current status of the packet
14
+ #
15
+ # @return [Hash] Current status details
16
+ # @raise [NotFoundError] If packet ID is not found
17
+ def current_status
18
+ ensure_packet_exists!
19
+
20
+ status = operations.packet_status(shipment.packeta_packet_id)
21
+ update_shipment_status(status)
22
+ status
23
+ end
24
+
25
+ # Get full tracking history
26
+ #
27
+ # @return [Array<Hash>] Array of tracking records
28
+ # @raise [NotFoundError] If packet ID is not found
29
+ def tracking_history
30
+ ensure_packet_exists!
31
+
32
+ operations.packet_tracking(shipment.packeta_packet_id)
33
+ end
34
+
35
+ # Sync status from Packeta and update shipment
36
+ #
37
+ # @return [Hash] Current status
38
+ def sync_status!
39
+ status = current_status
40
+
41
+ # Update last sync timestamp
42
+ shipment.update!(packeta_last_sync_at: Time.current)
43
+
44
+ # Update Spree shipment state based on Packeta status
45
+ update_spree_shipment_state(status)
46
+
47
+ status
48
+ end
49
+
50
+ # Check if shipment needs status sync
51
+ #
52
+ # @return [Boolean]
53
+ def needs_sync?
54
+ return true if shipment.packeta_last_sync_at.nil?
55
+
56
+ sync_interval = SpreePacketa.configuration.tracking_sync_interval
57
+ shipment.packeta_last_sync_at < sync_interval.seconds.ago
58
+ end
59
+
60
+ private
61
+
62
+ def ensure_packet_exists!
63
+ unless shipment.packeta_packet_id
64
+ raise NotFoundError, "Shipment #{shipment.id} does not have a Packeta packet"
65
+ end
66
+ end
67
+
68
+ def update_shipment_status(status)
69
+ shipment.update!(
70
+ packeta_status_code: status[:status_code],
71
+ packeta_branch_id: status[:branch_id],
72
+ packeta_last_sync_at: Time.current
73
+ )
74
+ end
75
+
76
+ def update_spree_shipment_state(status)
77
+ # Map Packeta status codes to Spree shipment states
78
+ # This is a simplified mapping - adjust based on your needs
79
+ case status[:status_code]
80
+ when 1, 2 # Packet created, In preparation
81
+ shipment.update!(state: 'pending') unless shipment.shipped?
82
+ when 3, 4 # Sent to carrier, In transit
83
+ shipment.ship! unless shipment.shipped?
84
+ when 5 # Delivered
85
+ shipment.ship! unless shipment.shipped?
86
+ when 6 # Returned
87
+ # Handle returns - could create a return authorization
88
+ Rails.logger.info("Packet #{shipment.packeta_packet_id} was returned")
89
+ end
90
+ rescue => e
91
+ Rails.logger.error("Failed to update shipment state: #{e.message}")
92
+ # Don't fail the sync if state transition fails
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'savon'
4
+
5
+ module SpreePacketa
6
+ module Soap
7
+ class Client
8
+ MAX_RETRIES = 3
9
+ RETRY_DELAY = 1 # second
10
+
11
+ attr_reader :client
12
+
13
+ def initialize
14
+ @client = build_client
15
+ end
16
+
17
+ # Call a SOAP operation with automatic error handling and retries
18
+ #
19
+ # @param operation [Symbol] The SOAP operation name
20
+ # @param message [Hash] The SOAP message body
21
+ # @return [Savon::Response] The SOAP response
22
+ # @raise [SpreePacketa::AuthenticationError] If API password is incorrect
23
+ # @raise [SpreePacketa::ValidationError] If packet attributes are invalid
24
+ # @raise [SpreePacketa::NotFoundError] If packet ID is not found
25
+ # @raise [SpreePacketa::NetworkError] If network error occurs
26
+ def call(operation, message = {})
27
+ retries = 0
28
+
29
+ begin
30
+ # Always include API password in the message
31
+ message_with_auth = message.merge(api_password: api_password)
32
+
33
+ response = client.call(operation, message: message_with_auth)
34
+ handle_response(response)
35
+ rescue Savon::SOAPFault => e
36
+ handle_soap_fault(e)
37
+ rescue Savon::HTTPError, Savon::InvalidResponseError => e
38
+ retries += 1
39
+ if retries <= MAX_RETRIES
40
+ sleep(RETRY_DELAY * retries)
41
+ retry
42
+ else
43
+ raise NetworkError, "Network error after #{MAX_RETRIES} retries: #{e.message}"
44
+ end
45
+ rescue StandardError => e
46
+ raise ApiError, "Unexpected error: #{e.message}"
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def build_client
53
+ Savon.client(
54
+ wsdl: wsdl_path,
55
+ endpoint: soap_endpoint,
56
+ namespace: 'http://www.zasilkovna.cz/api/soap.wsdl',
57
+ namespace_identifier: :tns,
58
+ env_namespace: :soap,
59
+ convert_request_keys_to: :none,
60
+ pretty_print_xml: Rails.env.development?,
61
+ log: Rails.env.development?,
62
+ log_level: :debug,
63
+ logger: Rails.logger,
64
+ open_timeout: 10,
65
+ read_timeout: 30,
66
+ raise_errors: true
67
+ )
68
+ end
69
+
70
+ def handle_response(response)
71
+ # Log the response in development
72
+ Rails.logger.debug("Packeta SOAP Response: #{response.body}") if Rails.env.development?
73
+ response
74
+ end
75
+
76
+ def handle_soap_fault(fault)
77
+ fault_code = fault.to_hash.dig(:fault, :faultcode)
78
+ fault_string = fault.to_hash.dig(:fault, :faultstring)
79
+
80
+ case fault_code
81
+ when /IncorrectApiPasswordFault/
82
+ raise AuthenticationError, "Incorrect API password: #{fault_string}"
83
+ when /PacketAttributesFault/
84
+ raise ValidationError, "Invalid packet attributes: #{fault_string}"
85
+ when /PacketIdFault/
86
+ raise NotFoundError, "Packet not found: #{fault_string}"
87
+ when /CancelNotAllowedFault/
88
+ raise ValidationError, "Cancel not allowed: #{fault_string}"
89
+ when /TooLateToUpdateCodFault/
90
+ raise ValidationError, "Too late to update COD: #{fault_string}"
91
+ when /ArgumentsFault/
92
+ raise ValidationError, "Invalid arguments: #{fault_string}"
93
+ else
94
+ raise ApiError, "SOAP fault: #{fault_code} - #{fault_string}"
95
+ end
96
+ end
97
+
98
+ def api_password
99
+ SpreePacketa.configuration.api_password || raise(AuthenticationError, 'API password not configured')
100
+ end
101
+
102
+ def soap_endpoint
103
+ SpreePacketa.configuration.soap_endpoint
104
+ end
105
+
106
+ def wsdl_path
107
+ # Prefer configured path, then bundled local file, finally remote URL
108
+ SpreePacketa.configuration.wsdl_path.presence || local_wsdl_path || 'https://www.zasilkovna.cz/api/soap.wsdl'
109
+ end
110
+
111
+ def local_wsdl_path
112
+ path = File.expand_path('packeta.wsdl', __dir__)
113
+ File.exist?(path) ? path : nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SpreePacketa
4
+ module Soap
5
+ class Operations
6
+ attr_reader :client
7
+
8
+ def initialize(client = nil)
9
+ @client = client || Client.new
10
+ end
11
+
12
+ # Validate packet attributes before creating a packet
13
+ #
14
+ # @param attributes [Hash] Packet attributes
15
+ # @return [Boolean] True if valid
16
+ # @raise [ValidationError] If attributes are invalid
17
+ def packet_attributes_valid?(attributes)
18
+ response = client.call(:packet_attributes_valid, attributes: packet_attributes_hash(attributes))
19
+ true
20
+ rescue ValidationError => e
21
+ Rails.logger.error("Packet attributes validation failed: #{e.message}")
22
+ raise
23
+ end
24
+
25
+ # Create a new packet
26
+ #
27
+ # @param attributes [Hash] Packet attributes
28
+ # @option attributes [String] :number Order number
29
+ # @option attributes [String] :name Customer first name
30
+ # @option attributes [String] :surname Customer last name
31
+ # @option attributes [String] :email Customer email
32
+ # @option attributes [String] :phone Customer phone
33
+ # @option attributes [Integer] :address_id Packeta pickup point ID
34
+ # @option attributes [BigDecimal] :value Package value
35
+ # @option attributes [BigDecimal] :weight Package weight in kg
36
+ # @option attributes [BigDecimal] :cod Cash on delivery amount (optional)
37
+ # @option attributes [String] :currency Currency code (CZK, EUR, etc.)
38
+ # @return [Hash] Created packet details with :id, :barcode, :barcode_text
39
+ def create_packet(attributes)
40
+ response = client.call(:create_packet, attributes: packet_attributes_hash(attributes))
41
+
42
+ result = response.body[:create_packet_response][:create_packet_result]
43
+
44
+ {
45
+ id: result[:id],
46
+ barcode: result[:barcode],
47
+ barcode_text: result[:barcode_text]
48
+ }
49
+ end
50
+
51
+ # Get current status of a packet
52
+ #
53
+ # @param packet_id [Integer] Packeta packet ID
54
+ # @return [Hash] Current status with :date_time, :status_code, :code_text, :status_text, :branch_id
55
+ def packet_status(packet_id)
56
+ response = client.call(:packet_status, packet_id: packet_id)
57
+
58
+ result = response.body[:packet_status_response][:packet_status_result]
59
+
60
+ {
61
+ date_time: result[:date_time],
62
+ status_code: result[:status_code],
63
+ code_text: result[:code_text],
64
+ status_text: result[:status_text],
65
+ branch_id: result[:branch_id],
66
+ destination_branch_id: result[:destination_branch_id],
67
+ is_returning: result[:is_returning],
68
+ stored_until: result[:stored_until]
69
+ }
70
+ end
71
+
72
+ # Get full tracking history for a packet
73
+ #
74
+ # @param packet_id [Integer] Packeta packet ID
75
+ # @return [Array<Hash>] Array of tracking records
76
+ def packet_tracking(packet_id)
77
+ response = client.call(:packet_tracking, packet_id: packet_id)
78
+
79
+ records = response.body[:packet_tracking_response][:packet_tracking_result][:record]
80
+ records = [records] unless records.is_a?(Array) # Handle single record
81
+
82
+ records.map do |record|
83
+ {
84
+ date_time: record[:date_time],
85
+ status_code: record[:status_code],
86
+ code_text: record[:code_text],
87
+ status_text: record[:status_text],
88
+ branch_id: record[:branch_id],
89
+ destination_branch_id: record[:destination_branch_id]
90
+ }
91
+ end
92
+ end
93
+
94
+ # Cancel a packet (if allowed)
95
+ #
96
+ # @param packet_id [Integer] Packeta packet ID
97
+ # @return [Boolean] True if cancelled successfully
98
+ def cancel_packet(packet_id)
99
+ client.call(:cancel_packet, packet_id: packet_id)
100
+ true
101
+ end
102
+
103
+ # Get packet label as PDF
104
+ #
105
+ # @param packet_id [Integer] Packeta packet ID
106
+ # @param format [String] Label format ('A7 on A4', 'A6 on A4', etc.)
107
+ # @param offset [Integer] Offset for positioning on page (0-3)
108
+ # @return [String] Base64 encoded PDF
109
+ def packet_label_pdf(packet_id, format: 'A7 on A4', offset: 0)
110
+ response = client.call(
111
+ :packet_label_pdf,
112
+ packet_id: packet_id,
113
+ format: format,
114
+ offset: offset
115
+ )
116
+
117
+ response.body[:packet_label_pdf_response][:packet_label_pdf_result]
118
+ end
119
+
120
+ private
121
+
122
+ # Convert Spree-friendly attributes to Packeta SOAP format
123
+ def packet_attributes_hash(attributes)
124
+ {
125
+ number: attributes[:number],
126
+ name: attributes[:name],
127
+ surname: attributes[:surname],
128
+ email: attributes[:email],
129
+ phone: attributes[:phone],
130
+ address_id: attributes[:address_id],
131
+ cod: attributes[:cod] || 0,
132
+ currency: attributes[:currency] || 'CZK',
133
+ value: attributes[:value],
134
+ weight: attributes[:weight],
135
+ eshop: attributes[:eshop] || SpreePacketa.configuration.eshop
136
+ }.compact
137
+ end
138
+ end
139
+ end
140
+ end