spree_delhivery 1.0.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.
- checksums.yaml +7 -0
- data/README.md +175 -0
- data/Rakefile +21 -0
- data/app/assets/config/spree_delhivery_manifest.js +4 -0
- data/app/assets/images/integration_icons/delhivery.png +0 -0
- data/app/assets/images/payment_icons/delhivery.svg +12 -0
- data/app/assets/images/payment_icons/delhivery_cod.svg +12 -0
- data/app/controllers/spree/admin/delhivery_controller.rb +190 -0
- data/app/controllers/spree/admin/delhivery_returns_controller.rb +82 -0
- data/app/controllers/spree/admin/fulfillments_controller.rb +117 -0
- data/app/controllers/spree/admin/shipments_controller_decorator.rb +198 -0
- data/app/controllers/spree/admin/stock_locations_controller_decorator.rb +38 -0
- data/app/controllers/spree/api/v3/store/delhivery_controller.rb +126 -0
- data/app/jobs/spree_delhivery/base_job.rb +5 -0
- data/app/models/spree/calculator/shipping/delhivery.rb +97 -0
- data/app/models/spree/integrations/delhivery.rb +48 -0
- data/app/models/spree/order_decorator.rb +63 -0
- data/app/models/spree/page_blocks/products/delhivery_edd.rb +42 -0
- data/app/models/spree/page_sections/product_details_decorator.rb +26 -0
- data/app/models/spree/payment_method/delhivery_cod.rb +57 -0
- data/app/services/spree_delhivery/client.rb +281 -0
- data/app/services/spree_delhivery/pickup_service.rb +49 -0
- data/app/services/spree_delhivery/shipment_canceler.rb +59 -0
- data/app/services/spree_delhivery/shipment_sender.rb +210 -0
- data/app/services/spree_delhivery/shipment_tracker.rb +50 -0
- data/app/views/spree/admin/fulfillments/new.html.erb +118 -0
- data/app/views/spree/admin/integrations/forms/_delhivery.html.erb +51 -0
- data/app/views/spree/admin/orders/_shipment.html.erb +180 -0
- data/app/views/spree/admin/orders/return_authorizations/_return_authorization.html.erb +157 -0
- data/app/views/spree/admin/page_blocks/forms/_delhivery_edd.html.erb +157 -0
- data/app/views/spree/admin/payment_methods/configuration_guides/_delhivery_cod.html.erb +71 -0
- data/app/views/spree/admin/payment_methods/descriptions/_delhivery_cod.html.erb +7 -0
- data/app/views/spree/admin/return_authorizations/index.html.erb +143 -0
- data/app/views/spree/admin/shipments/edit.html.erb +40 -0
- data/app/views/spree/admin/stock_locations/_delhivery_fields.html.erb +19 -0
- data/app/views/spree/admin/stock_locations/_form.html.erb +184 -0
- data/app/views/spree/checkout/payment/_delhivery_cod.html.erb +9 -0
- data/app/views/spree/page_blocks/products/delhivery_edd/_delhivery_edd.html.erb +239 -0
- data/app/views/spree_delhivery/_head.html.erb +0 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/spree.rb +15 -0
- data/config/initializers/spree_permitted_attributes.rb +4 -0
- data/config/locales/en.yml +36 -0
- data/config/routes.rb +42 -0
- data/db/migrate/20250101000001_add_delhivery_fields_to_shipments.rb +10 -0
- data/db/migrate/20250101000002_add_tracking_status_to_shipments.rb +13 -0
- data/db/migrate/20251227110851_add_delhivery_fields_to_spree_stock_locations.rb +5 -0
- data/db/migrate/20251227112401_add_geolocation_to_stock_locations.rb +9 -0
- data/db/migrate/20251227123158_add_missing_coordinates_to_stock_locations.rb +18 -0
- data/db/migrate/20251228081459_add_delhivery_to_return_authorizations.rb +8 -0
- data/lib/generators/spree_delhivery/install/install_generator.rb +139 -0
- data/lib/spree_delhivery/configuration.rb +13 -0
- data/lib/spree_delhivery/engine.rb +39 -0
- data/lib/spree_delhivery/factories.rb +6 -0
- data/lib/spree_delhivery/version.rb +7 -0
- data/lib/spree_delhivery.rb +13 -0
- data/lib/tasks/delhivery.rake +60 -0
- metadata +151 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
class PaymentMethod::DelhiveryCod < PaymentMethod
|
|
3
|
+
def method_type
|
|
4
|
+
'delhivery_cod'
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def payment_icon_name
|
|
8
|
+
'delhivery_cod'
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description_partial_name
|
|
12
|
+
'delhivery_cod'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def configuration_guide_partial_name
|
|
16
|
+
'delhivery_cod'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def source_required?
|
|
20
|
+
false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def auto_capture?
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def actions
|
|
28
|
+
%w{authorize capture void purchase}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def can_capture?(payment)
|
|
32
|
+
['checkout', 'pending'].include?(payment.state)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def can_void?(payment)
|
|
36
|
+
payment.state != 'void'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Satisfies the Spree API and Rails Checkout authorization step
|
|
40
|
+
def authorize(*args)
|
|
41
|
+
ActiveMerchant::Billing::Response.new(true, "Delhivery COD Authorized", {}, {})
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Satisfies checkout engines that try to purchase immediately
|
|
45
|
+
def purchase(*args)
|
|
46
|
+
ActiveMerchant::Billing::Response.new(true, "Delhivery COD Purchased", {}, {})
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def capture(*args)
|
|
50
|
+
ActiveMerchant::Billing::Response.new(true, "Delhivery COD Captured", {}, {})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def void(*args)
|
|
54
|
+
ActiveMerchant::Billing::Response.new(true, "Delhivery COD Voided", {}, {})
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
require 'httparty'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module SpreeDelhivery
|
|
6
|
+
class Client
|
|
7
|
+
include HTTParty
|
|
8
|
+
|
|
9
|
+
# Base Configuration
|
|
10
|
+
base_uri 'https://track.delhivery.com'
|
|
11
|
+
|
|
12
|
+
attr_reader :integration
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@integration = Spree::Integrations::Delhivery.active.first
|
|
16
|
+
raise "Delhivery Integration is not active or configured" unless @integration
|
|
17
|
+
|
|
18
|
+
@api_token = @integration.preferred_api_token.to_s.strip
|
|
19
|
+
|
|
20
|
+
# Environment Logic
|
|
21
|
+
if @integration.preferred_production_mode
|
|
22
|
+
self.class.base_uri 'https://track.delhivery.com'
|
|
23
|
+
@env_name = "PRODUCTION"
|
|
24
|
+
else
|
|
25
|
+
self.class.base_uri 'https://staging-express.delhivery.com'
|
|
26
|
+
@env_name = "STAGING"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Fetch Shipping Rate
|
|
31
|
+
def fetch_shipping_rate(source_pin:, dest_pin:, weight_gms:, mode: 'S')
|
|
32
|
+
path = "/api/kinko/v1/invoice/charges/.json"
|
|
33
|
+
api_mode = map_mode(mode)
|
|
34
|
+
|
|
35
|
+
params = {
|
|
36
|
+
md: api_mode,
|
|
37
|
+
ss: 'Delivered',
|
|
38
|
+
d_pin: dest_pin,
|
|
39
|
+
o_pin: source_pin,
|
|
40
|
+
cgm: weight_gms,
|
|
41
|
+
pt: 'Pre-paid'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
response = self.class.get(path, query: params, headers: auth_headers)
|
|
45
|
+
data = response.parsed_response
|
|
46
|
+
|
|
47
|
+
if data.is_a?(Hash) && data['total_amount']
|
|
48
|
+
return data['total_amount'].to_f
|
|
49
|
+
elsif data.is_a?(Array) && data.first && data.first['total_amount']
|
|
50
|
+
return data.first['total_amount'].to_f
|
|
51
|
+
else
|
|
52
|
+
return nil
|
|
53
|
+
end
|
|
54
|
+
rescue => e
|
|
55
|
+
Rails.logger.error "[Delhivery] Rate Exception: #{e.message}"
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Calculate TAT
|
|
60
|
+
def calculate_tat(source_pin:, dest_pin:, mode: 'S')
|
|
61
|
+
path = "/api/dc/expected_tat"
|
|
62
|
+
api_mode = map_mode(mode)
|
|
63
|
+
|
|
64
|
+
params = {
|
|
65
|
+
origin_pin: source_pin,
|
|
66
|
+
destination_pin: dest_pin,
|
|
67
|
+
mot: api_mode,
|
|
68
|
+
pdt: 'Pre-paid',
|
|
69
|
+
token: @api_token
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
response = self.class.get(path, query: params, headers: auth_headers)
|
|
73
|
+
response.parsed_response.is_a?(Hash) ? response.parsed_response : nil
|
|
74
|
+
rescue => e
|
|
75
|
+
Rails.logger.error "[Delhivery] TAT Error: #{e.message}"
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Fetch Pincode Details
|
|
80
|
+
def fetch_pincode_details(pincode)
|
|
81
|
+
path = "/c/api/pin-codes/json/"
|
|
82
|
+
response = self.class.get(path, query: { filter_codes: pincode }, headers: auth_headers)
|
|
83
|
+
data = response.parsed_response
|
|
84
|
+
|
|
85
|
+
find_city = ->(obj) do
|
|
86
|
+
case obj
|
|
87
|
+
when Hash
|
|
88
|
+
return obj if obj.key?('city') || obj.key?(:city)
|
|
89
|
+
obj.each_value { |v| res = find_city.call(v); return res if res }
|
|
90
|
+
when Array
|
|
91
|
+
obj.each { |v| res = find_city.call(v); return res if res }
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
find_city.call(data)
|
|
97
|
+
rescue => e
|
|
98
|
+
Rails.logger.error "[Delhivery] City Error: #{e.message}"
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Create Return Shipment
|
|
103
|
+
def create_return_request(return_auth, options = {})
|
|
104
|
+
order = return_auth.order
|
|
105
|
+
stock_location = return_auth.stock_location
|
|
106
|
+
customer_address = order.ship_address
|
|
107
|
+
|
|
108
|
+
# Defaults if not provided
|
|
109
|
+
brand_name = options[:brand].presence || @integration.preferred_client_name
|
|
110
|
+
category_name = options[:category].presence || "General"
|
|
111
|
+
|
|
112
|
+
# Data Cleaning
|
|
113
|
+
clean_phone = ->(p) { p.to_s.gsub(/[^0-9]/, '').last(10) }
|
|
114
|
+
clean_str = ->(s) { s.to_s.gsub(/[^0-9a-zA-Z\s,\.\-]/, ' ').strip.first(100) }
|
|
115
|
+
|
|
116
|
+
c_phone = clean_phone.call(customer_address.phone)
|
|
117
|
+
w_phone = clean_phone.call(stock_location.phone)
|
|
118
|
+
|
|
119
|
+
# Construct Custom QC Items
|
|
120
|
+
custom_qc_items = []
|
|
121
|
+
|
|
122
|
+
return_auth.return_items.each do |ri|
|
|
123
|
+
variant = ri.inventory_unit.variant
|
|
124
|
+
|
|
125
|
+
img_url = "https://via.placeholder.com/150"
|
|
126
|
+
if variant.images.any?
|
|
127
|
+
img_url = variant.images.first.attachment.url(:small) rescue img_url
|
|
128
|
+
elsif variant.product.images.any?
|
|
129
|
+
img_url = variant.product.images.first.attachment.url(:small) rescue img_url
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
reason_text = "Customer Return"
|
|
133
|
+
if ri.respond_to?(:return_reason) && ri.return_reason.present?
|
|
134
|
+
reason_text = ri.return_reason.name
|
|
135
|
+
elsif return_auth.respond_to?(:reason) && return_auth.reason.present?
|
|
136
|
+
reason_text = return_auth.reason.name
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
custom_qc_items << {
|
|
140
|
+
"item" => variant.name.first(30),
|
|
141
|
+
"description" => variant.product.description&.first(50) || variant.name,
|
|
142
|
+
"images" => [img_url],
|
|
143
|
+
"return_reason" => reason_text,
|
|
144
|
+
"quantity" => 1,
|
|
145
|
+
"brand" => brand_name, # <--- DYNAMIC
|
|
146
|
+
"product_category" => category_name, # <--- DYNAMIC
|
|
147
|
+
"questions" => []
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Calculate Weight
|
|
152
|
+
total_weight_gms = 0.0
|
|
153
|
+
return_auth.inventory_units.each do |unit|
|
|
154
|
+
w = unit.variant.weight.to_f
|
|
155
|
+
w = (w < 50) ? w * 1000.0 : w
|
|
156
|
+
total_weight_gms += w
|
|
157
|
+
end
|
|
158
|
+
total_weight_gms = 500 if total_weight_gms < 500
|
|
159
|
+
|
|
160
|
+
# Payload
|
|
161
|
+
payload = {
|
|
162
|
+
"shipments" => [
|
|
163
|
+
{
|
|
164
|
+
"client" => @integration.preferred_client_name,
|
|
165
|
+
"order" => return_auth.number,
|
|
166
|
+
"waybill" => "",
|
|
167
|
+
"name" => customer_address.full_name,
|
|
168
|
+
"add" => clean_str.call(customer_address.address1),
|
|
169
|
+
"city" => customer_address.city,
|
|
170
|
+
"state" => customer_address.state&.name || customer_address.state_name,
|
|
171
|
+
"country" => "India",
|
|
172
|
+
"phone" => c_phone,
|
|
173
|
+
"pin" => customer_address.zipcode,
|
|
174
|
+
|
|
175
|
+
"return_name" => stock_location.name,
|
|
176
|
+
"return_add" => clean_str.call(stock_location.address1),
|
|
177
|
+
"return_city" => stock_location.city,
|
|
178
|
+
"return_state" => stock_location.state&.name || stock_location.state_name,
|
|
179
|
+
"return_country" => "India",
|
|
180
|
+
"return_pin" => stock_location.zipcode,
|
|
181
|
+
"return_phone" => w_phone,
|
|
182
|
+
|
|
183
|
+
"payment_mode" => "Pickup",
|
|
184
|
+
"products_desc" => "Return #{order.number}",
|
|
185
|
+
"quantity" => return_auth.return_items.count,
|
|
186
|
+
"weight" => total_weight_gms.to_i,
|
|
187
|
+
"total_amount" => 0,
|
|
188
|
+
"shipping_mode" => "Surface",
|
|
189
|
+
"order_date" => Time.current.strftime("%d-%m-%Y"),
|
|
190
|
+
|
|
191
|
+
"qc_type" => "param",
|
|
192
|
+
"custom_qc" => custom_qc_items
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
"pickup_location" => {
|
|
196
|
+
"name" => stock_location.delhivery_warehouse_name
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
Rails.logger.info "[Delhivery] RVP Payload: #{payload.to_json}"
|
|
201
|
+
|
|
202
|
+
response = self.class.post(
|
|
203
|
+
"/api/cmu/create.json",
|
|
204
|
+
body: { "format" => "json", "data" => payload.to_json },
|
|
205
|
+
headers: { "Authorization" => "Token #{@api_token}" }
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
JSON.parse(response.body) rescue { "error" => "Invalid JSON Response" }
|
|
209
|
+
|
|
210
|
+
rescue StandardError => e
|
|
211
|
+
Rails.logger.error "Delhivery Return Exception: #{e.message}"
|
|
212
|
+
{ "error" => e.message }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# 5. Forward Shipment
|
|
216
|
+
def create_shipment(payload_data)
|
|
217
|
+
response = self.class.post("/api/cmu/create.json",
|
|
218
|
+
body: { "format" => "json", "data" => payload_data.to_json },
|
|
219
|
+
headers: { "Authorization" => "Token #{@api_token}" }
|
|
220
|
+
)
|
|
221
|
+
JSON.parse(response.body) rescue {}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# 6. Fetch Wallet Balance
|
|
225
|
+
def fetch_balance
|
|
226
|
+
# This is the standard endpoint for checking Delhivery wallet balance
|
|
227
|
+
response = self.class.get("/api/client/get_balance_ledger.json", headers: auth_headers)
|
|
228
|
+
|
|
229
|
+
if response.success? && response.parsed_response.is_a?(Hash)
|
|
230
|
+
# Returns format like: { "cash_balance" => "150.00", ... }
|
|
231
|
+
return response.parsed_response['cash_balance']
|
|
232
|
+
end
|
|
233
|
+
nil
|
|
234
|
+
rescue => e
|
|
235
|
+
Rails.logger.error "[Delhivery] Balance Fetch Failed: #{e.message}"
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def track_shipment(waybill)
|
|
240
|
+
send_get_request("/api/v1/packages/json/?waybill=#{waybill}")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def fetch_label(waybill)
|
|
244
|
+
send_get_request("/api/p/packing_slip?wbns=#{waybill}&pdf=true")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def create_pickup_request(location_name:, date:, time:, count: 1)
|
|
248
|
+
payload = { pickup_location: location_name, pickup_date: date, pickup_time: time, expected_package_count: count }
|
|
249
|
+
post_json("/fm/request/new/", payload)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def cancel_shipment(waybill)
|
|
253
|
+
post_json("/api/p/edit", { waybill: waybill, cancellation: true })
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
def map_mode(val)
|
|
259
|
+
str = val.to_s.downcase.strip
|
|
260
|
+
['express', 'e'].include?(str) ? 'E' : 'S'
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def send_get_request(path, params = {})
|
|
264
|
+
response = self.class.get(path, query: params, headers: auth_headers)
|
|
265
|
+
response.parsed_response
|
|
266
|
+
rescue => e
|
|
267
|
+
{ "error" => "Request Failed", "details" => e.message }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def post_json(path, body = {})
|
|
271
|
+
response = self.class.post(path, body: body.to_json, headers: auth_headers.merge('Content-Type' => 'application/json'))
|
|
272
|
+
response.parsed_response
|
|
273
|
+
rescue => e
|
|
274
|
+
{ "error" => "Request Failed", "details" => e.message }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def auth_headers
|
|
278
|
+
{ "Authorization" => "Token #{@api_token}", "Accept" => "application/json" }
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module SpreeDelhivery
|
|
2
|
+
class PickupService
|
|
3
|
+
Result = Struct.new(:success?, :message, :data)
|
|
4
|
+
|
|
5
|
+
def initialize(stock_location, date: nil, time: "16:00:00", count: 1)
|
|
6
|
+
@stock_location = stock_location
|
|
7
|
+
@date = date || Date.tomorrow.strftime('%Y-%m-%d')
|
|
8
|
+
@time = time
|
|
9
|
+
@count = count
|
|
10
|
+
@client = SpreeDelhivery::Client.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
# 1. Validation
|
|
15
|
+
unless @stock_location.delhivery_warehouse_name.present?
|
|
16
|
+
return Result.new(false, "Stock Location is missing Warehouse Name.")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# 2. Call API
|
|
20
|
+
begin
|
|
21
|
+
response = @client.create_pickup_request(
|
|
22
|
+
location_name: @stock_location.delhivery_warehouse_name,
|
|
23
|
+
date: @date,
|
|
24
|
+
time: @time,
|
|
25
|
+
count: @count
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# --- [DEBUG] START: Add this line ---
|
|
29
|
+
puts "\n\n🔴 [DELHIVERY DEBUG] RAW RESPONSE: #{response.inspect}\n\n"
|
|
30
|
+
# --- [DEBUG] END ---
|
|
31
|
+
|
|
32
|
+
# 3. Parse Response
|
|
33
|
+
if response['pickup_id'].present?
|
|
34
|
+
return Result.new(true, "Pickup Scheduled! ID: #{response['pickup_id']}", response)
|
|
35
|
+
elsif response['error'].present?
|
|
36
|
+
return Result.new(false, response['error'])
|
|
37
|
+
else
|
|
38
|
+
# Fallback
|
|
39
|
+
msg = response['pre_feed_back'] || "Unknown Response: #{response}"
|
|
40
|
+
return Result.new(false, msg)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
Rails.logger.error "[Delhivery] Pickup Error: #{e.message}"
|
|
45
|
+
return Result.new(false, e.message)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module SpreeDelhivery
|
|
2
|
+
class ShipmentCanceler
|
|
3
|
+
def initialize(shipment)
|
|
4
|
+
@shipment = shipment
|
|
5
|
+
@client = SpreeDelhivery::Client.new
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
waybill = @shipment.delhivery_waybill
|
|
10
|
+
return error("No Waybill found to cancel") unless waybill.present?
|
|
11
|
+
|
|
12
|
+
# 1. Call API
|
|
13
|
+
begin
|
|
14
|
+
response = @client.cancel_shipment(waybill)
|
|
15
|
+
rescue StandardError => e
|
|
16
|
+
return error("API Connection Error: #{e.message}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Delhivery success response is usually { "status" => true/false, ... } or "success" => true
|
|
20
|
+
# Sometimes it returns specific codes. We check general success/failure keys.
|
|
21
|
+
api_success = response['success'] || response['status'] == true || response['status'] == "Success"
|
|
22
|
+
|
|
23
|
+
if api_success
|
|
24
|
+
# 2. Clear Data & Revert State in Spree
|
|
25
|
+
# We use update_columns to skip state machine transitions/validations
|
|
26
|
+
@shipment.update_columns(
|
|
27
|
+
tracking: nil,
|
|
28
|
+
delhivery_waybill: nil,
|
|
29
|
+
delhivery_ref_id: nil,
|
|
30
|
+
delhivery_label_url: nil,
|
|
31
|
+
delhivery_response_data: nil,
|
|
32
|
+
state: 'ready', # Move back to Ready
|
|
33
|
+
shipped_at: nil # Clear the shipped timestamp
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# 3. Update the Order's overall shipment state
|
|
37
|
+
# This ensures the order status bar updates correctly
|
|
38
|
+
@shipment.order.updater.update_shipment_state
|
|
39
|
+
@shipment.order.save
|
|
40
|
+
|
|
41
|
+
return success
|
|
42
|
+
else
|
|
43
|
+
return error(response['error'] || response['message'] || "Failed to cancel at Delhivery")
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
return error(e.message)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def success
|
|
52
|
+
OpenStruct.new(success?: true)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def error(msg)
|
|
56
|
+
OpenStruct.new(success?: false, error: msg)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
module SpreeDelhivery
|
|
2
|
+
class ShipmentSender
|
|
3
|
+
def initialize(shipment)
|
|
4
|
+
@shipment = shipment
|
|
5
|
+
@order = shipment.order
|
|
6
|
+
@address = @order.ship_address
|
|
7
|
+
@client = SpreeDelhivery::Client.new
|
|
8
|
+
@integration = @client.integration
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
return error("Shipment already has a Waybill") if @shipment.delhivery_waybill.present?
|
|
13
|
+
|
|
14
|
+
payload = build_payload
|
|
15
|
+
|
|
16
|
+
# Send API Request
|
|
17
|
+
response = @client.create_shipment(payload)
|
|
18
|
+
|
|
19
|
+
# Check for explicit success or package success status
|
|
20
|
+
success_status = response['success'] ||
|
|
21
|
+
(response['packages'].present? && response['packages'][0]['status'] == 'Success')
|
|
22
|
+
|
|
23
|
+
if success_status
|
|
24
|
+
pkg_data = response['packages'].first
|
|
25
|
+
|
|
26
|
+
# 1. Update Data Columns Safely
|
|
27
|
+
# Using update_columns to bypass Spree callbacks that might confuse the hash response with a model
|
|
28
|
+
@shipment.update_columns(
|
|
29
|
+
tracking: pkg_data['waybill'],
|
|
30
|
+
delhivery_waybill: pkg_data['waybill'],
|
|
31
|
+
delhivery_ref_id: pkg_data['refnum'],
|
|
32
|
+
delhivery_response_data: response # Rails handles JSON serialization automatically
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# 2. Reload object to ensure fresh state
|
|
36
|
+
@shipment.reload
|
|
37
|
+
|
|
38
|
+
# 3. Fire Spree Shipment State Machine
|
|
39
|
+
# This moves state from 'ready' -> 'shipped'
|
|
40
|
+
if @shipment.can_ship?
|
|
41
|
+
@shipment.ship!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 4. Fetch Label URL immediately
|
|
45
|
+
begin
|
|
46
|
+
label_res = @client.fetch_label(pkg_data['waybill'])
|
|
47
|
+
if label_res['packages'] && label_res['packages'][0]['pdf_download_link']
|
|
48
|
+
@shipment.update_column(:delhivery_label_url, label_res['packages'][0]['pdf_download_link'])
|
|
49
|
+
end
|
|
50
|
+
rescue => e
|
|
51
|
+
Rails.logger.error("Delhivery Label Fetch Failed: #{e.message}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
return success(@shipment)
|
|
55
|
+
else
|
|
56
|
+
# --- IMPROVED ERROR HANDLING ---
|
|
57
|
+
# 1. Extract the deep error message first (it's more accurate than 'rmk')
|
|
58
|
+
raw_error = response.dig('packages', 0, 'remarks')&.join(', ') || response['rmk'] || "Unknown Error"
|
|
59
|
+
|
|
60
|
+
# 2. Map known API errors to friendly user messages
|
|
61
|
+
friendly_error = case raw_error.to_s.downcase
|
|
62
|
+
when /insufficient balance/
|
|
63
|
+
# Fetch live balance to show the user
|
|
64
|
+
current_bal = @client.fetch_balance rescue nil
|
|
65
|
+
msg = "Authorization Failed: Insufficient Delhivery Wallet Balance."
|
|
66
|
+
msg += " Current Balance: ₹#{current_bal}." if current_bal
|
|
67
|
+
msg + " Please recharge."
|
|
68
|
+
when /duplicate/
|
|
69
|
+
"Duplicate Order: This order ID has already been processed."
|
|
70
|
+
when /pincode/
|
|
71
|
+
"Serviceability Error: Pincode (#{@address.zipcode}) not serviceable."
|
|
72
|
+
else
|
|
73
|
+
"Delhivery Error: #{raw_error}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return error(friendly_error)
|
|
77
|
+
end
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
Rails.logger.error(e.backtrace.join("\n"))
|
|
80
|
+
return error(e.message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def build_payload
|
|
86
|
+
# --- ROBUST PAYMENT MODE DETECTION ---
|
|
87
|
+
# Instead of relying on order.paid?, look at valid payments assigned to the order
|
|
88
|
+
is_cod_payment = @order.payments.valid.any? do |payment|
|
|
89
|
+
payment.payment_method&.type == 'Spree::PaymentMethod::DelhiveryCod'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
payment_mode = is_cod_payment ? 'COD' : 'Prepaid'
|
|
93
|
+
|
|
94
|
+
# Sanitization: Ensure phone is exactly 10 digits (removes +91 or 0 prefix)
|
|
95
|
+
phone = @address.phone.to_s.gsub(/[^0-9]/, '').last(10)
|
|
96
|
+
|
|
97
|
+
# 1. Calculate and Convert Weight to Grams
|
|
98
|
+
total_weight_grams = calculate_total_weight
|
|
99
|
+
|
|
100
|
+
# 2. Calculate and Convert Dimensions to CM
|
|
101
|
+
dims = calculate_dimensions # Returns [L, W, H] in CM
|
|
102
|
+
|
|
103
|
+
# 3. Detect Shipping Mode dynamically from Customer Choice
|
|
104
|
+
shipping_method_name = @shipment.shipping_method&.name.to_s.downcase
|
|
105
|
+
|
|
106
|
+
final_shipping_mode = if shipping_method_name.include?('express')
|
|
107
|
+
'Express'
|
|
108
|
+
elsif shipping_method_name.include?('surface')
|
|
109
|
+
'Surface'
|
|
110
|
+
else
|
|
111
|
+
# Fallback to Admin Setting if name is generic (e.g. "Free Shipping")
|
|
112
|
+
@integration.preferred_shipping_mode || 'Surface'
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
pickup_location: {
|
|
117
|
+
name: @integration.preferred_pickup_location_name
|
|
118
|
+
},
|
|
119
|
+
shipments: [
|
|
120
|
+
{
|
|
121
|
+
name: @address.full_name,
|
|
122
|
+
add: [@address.address1, @address.address2].compact.join(', ').truncate(250),
|
|
123
|
+
pin: @address.zipcode,
|
|
124
|
+
city: @address.city,
|
|
125
|
+
state: @address.state&.name || @address.state_name,
|
|
126
|
+
country: 'India',
|
|
127
|
+
phone: phone,
|
|
128
|
+
order: @shipment.number,
|
|
129
|
+
payment_mode: payment_mode,
|
|
130
|
+
products_desc: @shipment.line_items.map { |i| i.variant.name }.join(', ').truncate(50),
|
|
131
|
+
|
|
132
|
+
# --- DYNAMIC COD COLLECTION VALUES ---
|
|
133
|
+
cod_amount: payment_mode == 'COD' ? @order.total.to_f : 0.0,
|
|
134
|
+
total_amount: @order.total.to_f,
|
|
135
|
+
|
|
136
|
+
# Use the detected mode ('Express' or 'Surface')
|
|
137
|
+
shipping_mode: final_shipping_mode,
|
|
138
|
+
quantity: @shipment.line_items.sum(&:quantity).to_i,
|
|
139
|
+
|
|
140
|
+
# Dynamic Values
|
|
141
|
+
weight: total_weight_grams,
|
|
142
|
+
shipment_length: dims[0],
|
|
143
|
+
shipment_width: dims[1],
|
|
144
|
+
shipment_height: dims[2],
|
|
145
|
+
|
|
146
|
+
# Client Name (Dynamic based on settings)
|
|
147
|
+
client: @integration.preferred_client_name
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def calculate_total_weight
|
|
154
|
+
raw_weight = @shipment.line_items.sum { |li| (li.variant.weight || 0) * li.quantity }
|
|
155
|
+
raw_weight = 0.5 if raw_weight.zero?
|
|
156
|
+
|
|
157
|
+
unit = @integration.preferred_store_weight_unit || 'kg'
|
|
158
|
+
|
|
159
|
+
grams = case unit
|
|
160
|
+
when 'kg' then raw_weight * 1000
|
|
161
|
+
when 'lbs' then raw_weight * 453.592
|
|
162
|
+
when 'oz' then raw_weight * 28.3495
|
|
163
|
+
when 'g' then raw_weight
|
|
164
|
+
else raw_weight * 1000
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
grams.to_i
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def calculate_dimensions
|
|
171
|
+
max_l = 0
|
|
172
|
+
max_w = 0
|
|
173
|
+
total_h = 0
|
|
174
|
+
|
|
175
|
+
@shipment.line_items.each do |line_item|
|
|
176
|
+
v = line_item.variant
|
|
177
|
+
q = line_item.quantity
|
|
178
|
+
|
|
179
|
+
l = (v.depth || 10).to_f
|
|
180
|
+
w = (v.width || 10).to_f
|
|
181
|
+
h = (v.height || 10).to_f
|
|
182
|
+
|
|
183
|
+
max_l = [max_l, l].max
|
|
184
|
+
max_w = [max_w, w].max
|
|
185
|
+
total_h += (h * q)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
unit = @integration.preferred_store_dimension_unit || 'cm'
|
|
189
|
+
|
|
190
|
+
[max_l, max_w, total_h].map do |val|
|
|
191
|
+
cm = case unit
|
|
192
|
+
when 'cm' then val
|
|
193
|
+
when 'in' then val * 2.54
|
|
194
|
+
when 'm' then val * 100
|
|
195
|
+
when 'mm' then val / 10.0
|
|
196
|
+
else val
|
|
197
|
+
end
|
|
198
|
+
cm.round(2)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def success(shipment)
|
|
203
|
+
OpenStruct.new(success?: true, shipment: shipment)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def error(message)
|
|
207
|
+
OpenStruct.new(success?: false, error: message)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|