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,198 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
module ShipmentsControllerDecorator
|
|
4
|
+
def self.prepended(base)
|
|
5
|
+
# Ensure we can access these methods inside the controller
|
|
6
|
+
base.helper_method :delhivery_integration
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# POST /admin/shipments/:id/delhivery_manifest
|
|
10
|
+
def delhivery_manifest
|
|
11
|
+
@shipment = Spree::Shipment.find(params[:id])
|
|
12
|
+
|
|
13
|
+
# 1. Validation
|
|
14
|
+
unless delhivery_integration
|
|
15
|
+
flash[:error] = "Delhivery Integration is not active."
|
|
16
|
+
return redirect_back(fallback_location: admin_orders_path)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
unless @shipment.stock_location.delhivery_warehouse_name.present?
|
|
20
|
+
flash[:error] = "Stock Location is missing 'Delhivery Warehouse Name'. Please configure it first."
|
|
21
|
+
return redirect_to edit_admin_stock_location_path(@shipment.stock_location)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# 2. Build Payload
|
|
25
|
+
# We delegate this complex logic to a dedicated helper method below
|
|
26
|
+
payload = build_delhivery_payload(@shipment)
|
|
27
|
+
|
|
28
|
+
# 3. Call API
|
|
29
|
+
client = SpreeDelhivery::Client.new
|
|
30
|
+
response = client.create_shipment(payload)
|
|
31
|
+
|
|
32
|
+
# 4. Handle Response
|
|
33
|
+
if response['packages'].present? && response['packages'][0]['status'] == 'Success'
|
|
34
|
+
# Success!
|
|
35
|
+
waybill = response['packages'][0]['waybill']
|
|
36
|
+
ref_id = response['packages'][0]['refnum'] # Our shipment number
|
|
37
|
+
|
|
38
|
+
@shipment.update!(
|
|
39
|
+
delhivery_waybill: waybill,
|
|
40
|
+
delhivery_ref_id: ref_id,
|
|
41
|
+
tracking: waybill,
|
|
42
|
+
state: 'shipped', # Mark as shipped in Spree immediately
|
|
43
|
+
shipped_at: Time.current,
|
|
44
|
+
delhivery_response_data: response
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
flash[:success] = "Delhivery Waybill Generated: #{waybill}"
|
|
48
|
+
else
|
|
49
|
+
# Error
|
|
50
|
+
error_msg = response['error'] || response['packages']&.first&.fetch('remarks', nil) || "Unknown API Error"
|
|
51
|
+
flash[:error] = "Delhivery Failed: #{error_msg}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
flash[:error] = "System Error: #{e.message}"
|
|
57
|
+
redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# POST /admin/shipments/:id/delhivery_track
|
|
61
|
+
def delhivery_track
|
|
62
|
+
@shipment = Spree::Shipment.find(params[:id])
|
|
63
|
+
|
|
64
|
+
if @shipment.delhivery_waybill.blank?
|
|
65
|
+
flash[:error] = "No Waybill found to track."
|
|
66
|
+
else
|
|
67
|
+
client = SpreeDelhivery::Client.new
|
|
68
|
+
response = client.track_shipment(@shipment.delhivery_waybill)
|
|
69
|
+
|
|
70
|
+
# Parse flexible response (sometimes Array, sometimes Hash)
|
|
71
|
+
data = response.is_a?(Array) ? response.first : response
|
|
72
|
+
|
|
73
|
+
if data && data['ShipmentData']
|
|
74
|
+
status = data['ShipmentData'][0]['Shipment']['Status']['Status'] rescue "Unknown"
|
|
75
|
+
@shipment.update(tracking_status: status)
|
|
76
|
+
flash[:success] = "Tracking Updated: #{status}"
|
|
77
|
+
else
|
|
78
|
+
flash[:warning] = "Tracking info not available yet."
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# POST /admin/shipments/:id/delhivery_cancel
|
|
86
|
+
def delhivery_cancel
|
|
87
|
+
@shipment = Spree::Shipment.find(params[:id])
|
|
88
|
+
|
|
89
|
+
client = SpreeDelhivery::Client.new
|
|
90
|
+
response = client.cancel_shipment(@shipment.delhivery_waybill)
|
|
91
|
+
|
|
92
|
+
# Delhivery cancellation response varies, usually checks for success code
|
|
93
|
+
if response['status'] == "True" || response['success'] == true
|
|
94
|
+
@shipment.update(
|
|
95
|
+
delhivery_waybill: nil,
|
|
96
|
+
tracking: nil,
|
|
97
|
+
state: 'ready' # Revert state so we can ship again
|
|
98
|
+
)
|
|
99
|
+
flash[:success] = "Shipment Cancelled successfully."
|
|
100
|
+
else
|
|
101
|
+
flash[:error] = "Cancellation Failed: #{response['error'] || response['message']}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# GET /admin/shipments/:id/delhivery_label
|
|
108
|
+
def delhivery_label
|
|
109
|
+
@shipment = Spree::Shipment.find(params[:id])
|
|
110
|
+
|
|
111
|
+
client = SpreeDelhivery::Client.new
|
|
112
|
+
response = client.fetch_label(@shipment.delhivery_waybill)
|
|
113
|
+
|
|
114
|
+
# API usually returns a JSON with a 'packages' array containing a 'pdf_download_link'
|
|
115
|
+
# Or sometimes raw PDF data depending on endpoint.
|
|
116
|
+
# Assuming the 'packing_slip' endpoint returns JSON with a URL:
|
|
117
|
+
if response['packages'].present? && response['packages'][0]['pdf_download_link'].present?
|
|
118
|
+
redirect_to response['packages'][0]['pdf_download_link'], allow_other_host: true
|
|
119
|
+
else
|
|
120
|
+
flash[:error] = "Label URL not found in response."
|
|
121
|
+
redirect_back(fallback_location: edit_admin_order_path(@shipment.order))
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def delhivery_integration
|
|
128
|
+
@delhivery_integration ||= Spree::Integrations::Delhivery.active.first
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------
|
|
132
|
+
# PAYLOAD BUILDER
|
|
133
|
+
# This maps Spree Shipment Data -> Delhivery JSON Format
|
|
134
|
+
# ---------------------------------------------------------
|
|
135
|
+
def build_delhivery_payload(shipment)
|
|
136
|
+
order = shipment.order
|
|
137
|
+
address = order.shipping_address
|
|
138
|
+
location = shipment.stock_location
|
|
139
|
+
|
|
140
|
+
# Calculate Weight (Convert to Grams if needed)
|
|
141
|
+
# Assuming Spree weight is in KG.
|
|
142
|
+
total_weight_kgs = shipment.line_items.sum { |li| li.variant.weight.to_f * li.quantity }
|
|
143
|
+
total_weight_gms = (total_weight_kgs * 1000).to_i
|
|
144
|
+
total_weight_gms = 500 if total_weight_gms < 500 # Minimum 500g
|
|
145
|
+
|
|
146
|
+
payment_mode = order.paid? ? 'Pre-paid' : 'COD'
|
|
147
|
+
cod_amount = payment_mode == 'COD' ? order.total.to_f : 0.0
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
shipments: [
|
|
151
|
+
{
|
|
152
|
+
name: "#{address.firstname} #{address.lastname}",
|
|
153
|
+
add: "#{address.address1} #{address.address2}",
|
|
154
|
+
pin: address.zipcode,
|
|
155
|
+
city: address.city,
|
|
156
|
+
state: address.state&.name || address.state_text,
|
|
157
|
+
country: address.country&.iso || "IN",
|
|
158
|
+
phone: address.phone,
|
|
159
|
+
order: shipment.number, # Ref ID
|
|
160
|
+
payment_mode: payment_mode,
|
|
161
|
+
return_pin: location.zipcode,
|
|
162
|
+
return_city: location.city,
|
|
163
|
+
return_phone: location.phone,
|
|
164
|
+
return_add: location.address1,
|
|
165
|
+
products_desc: shipment.line_items.map { |li| li.product.name }.join(', '),
|
|
166
|
+
hsn_code: "", # Add logic here if you store HSN codes on products
|
|
167
|
+
cod_amount: cod_amount,
|
|
168
|
+
order_date: order.completed_at&.strftime('%Y-%m-%d'),
|
|
169
|
+
total_amount: order.total.to_f,
|
|
170
|
+
seller_inv_date: Time.current.strftime('%Y-%m-%d'),
|
|
171
|
+
seller_name: location.delhivery_warehouse_name, # Critical!
|
|
172
|
+
seller_add: "#{location.address1} #{location.city}",
|
|
173
|
+
seller_inv: shipment.number,
|
|
174
|
+
quantity: shipment.line_items.sum(&:quantity),
|
|
175
|
+
waybill: "", # Blank for creation
|
|
176
|
+
shipment_width: 10, # Default or fetch from products
|
|
177
|
+
shipment_height: 10, # Default
|
|
178
|
+
shipment_depth: 10, # Default
|
|
179
|
+
shipment_weight: total_weight_gms
|
|
180
|
+
}
|
|
181
|
+
],
|
|
182
|
+
pickup_location: {
|
|
183
|
+
name: location.delhivery_warehouse_name,
|
|
184
|
+
add: location.address1,
|
|
185
|
+
city: location.city,
|
|
186
|
+
pin_code: location.zipcode,
|
|
187
|
+
country: location.country&.iso || "IN",
|
|
188
|
+
phone: location.phone
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Apply the decoration
|
|
198
|
+
Spree::Admin::ShipmentsController.prepend(Spree::Admin::ShipmentsControllerDecorator)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
module StockLocationsControllerDecorator
|
|
4
|
+
def delhivery_pickup
|
|
5
|
+
@stock_location = Spree::StockLocation.find(params[:id])
|
|
6
|
+
|
|
7
|
+
# 1. Capture Params
|
|
8
|
+
count = params[:count].to_i
|
|
9
|
+
count = 1 if count <= 0
|
|
10
|
+
|
|
11
|
+
date = params[:pickup_date] # Format: "YYYY-MM-DD" from HTML5 input
|
|
12
|
+
time = params[:pickup_time] # Format: "HH:MM"
|
|
13
|
+
|
|
14
|
+
# 2. Call Service
|
|
15
|
+
# passing the named arguments your service expects
|
|
16
|
+
service = SpreeDelhivery::PickupService.new(
|
|
17
|
+
@stock_location,
|
|
18
|
+
date: date,
|
|
19
|
+
time: time,
|
|
20
|
+
count: count
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = service.call
|
|
24
|
+
|
|
25
|
+
# 3. Handle Result
|
|
26
|
+
if result.success?
|
|
27
|
+
flash[:success] = result.message
|
|
28
|
+
else
|
|
29
|
+
flash[:error] = "Delhivery Error: #{result.message}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
redirect_to edit_admin_stock_location_path(@stock_location)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Spree::Admin::StockLocationsController.prepend(Spree::Admin::StockLocationsControllerDecorator)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Store
|
|
5
|
+
class DelhiveryController < ::Spree::Api::V3::BaseController
|
|
6
|
+
# V3 best practice: skip size checks for simple GET/POST validation requests
|
|
7
|
+
skip_before_action :ensure_payload_size, raise: false
|
|
8
|
+
skip_before_action :check_payload_size, raise: false
|
|
9
|
+
|
|
10
|
+
def check
|
|
11
|
+
pincode = params[:pincode]
|
|
12
|
+
mode = params[:mode] || 'Surface'
|
|
13
|
+
|
|
14
|
+
cutoff_h = (params[:cutoff_hour] || '14').to_i
|
|
15
|
+
cutoff_m = params[:cutoff_meridiem] || 'PM'
|
|
16
|
+
|
|
17
|
+
return render json: { error: "Invalid Pincode" }, status: 400 if pincode.blank? || pincode.length != 6
|
|
18
|
+
|
|
19
|
+
# Determine Source Pin using Spree 5.4 methods
|
|
20
|
+
stock_location = Spree::StockLocation.where(active: true).where.not(zipcode: nil).first
|
|
21
|
+
source_pin = stock_location&.zipcode || "110001"
|
|
22
|
+
|
|
23
|
+
client = SpreeDelhivery::Client.new
|
|
24
|
+
|
|
25
|
+
# --- 1. IMPROVED LOCATION LOGIC (City + District + State) ---
|
|
26
|
+
location_text = "Valid Location"
|
|
27
|
+
begin
|
|
28
|
+
details = client.fetch_pincode_details(pincode)
|
|
29
|
+
if details
|
|
30
|
+
d = details.with_indifferent_access
|
|
31
|
+
|
|
32
|
+
# Clean City (Remove S.O/B.O suffixes)
|
|
33
|
+
raw_city = d[:city].presence || ""
|
|
34
|
+
city_name = raw_city.to_s.gsub(/\s+(S\.?O|B\.?O|H\.?O)\.?$/i, '').strip.titleize
|
|
35
|
+
|
|
36
|
+
# Clean District
|
|
37
|
+
raw_dist = d[:district].presence || ""
|
|
38
|
+
dist_name = raw_dist.to_s.gsub(/\s+District$/i, '').strip.titleize
|
|
39
|
+
|
|
40
|
+
# Parse State
|
|
41
|
+
raw_state = (d[:state] || d[:state_code] || d[:province]).to_s.strip
|
|
42
|
+
state_name = raw_state.titleize
|
|
43
|
+
|
|
44
|
+
# India State Map Correction
|
|
45
|
+
state_map = {
|
|
46
|
+
'TS' => 'Telangana', 'TG' => 'Telangana', 'DL' => 'Delhi',
|
|
47
|
+
'MH' => 'Maharashtra', 'KA' => 'Karnataka', 'TN' => 'Tamil Nadu',
|
|
48
|
+
'UP' => 'Uttar Pradesh', 'WB' => 'West Bengal', 'AP' => 'Andhra Pradesh',
|
|
49
|
+
'GJ' => 'Gujarat', 'RJ' => 'Rajasthan', 'KL' => 'Kerala', 'HR' => 'Haryana',
|
|
50
|
+
'PB' => 'Punjab', 'MP' => 'Madhya Pradesh', 'BR' => 'Bihar',
|
|
51
|
+
'CG' => 'Chhattisgarh', 'JH' => 'Jharkhand', 'UK' => 'Uttarakhand',
|
|
52
|
+
'HP' => 'Himachal Pradesh', 'AS' => 'Assam', 'OR' => 'Odisha'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Resolve Official State Name via Spree DB
|
|
56
|
+
india = Spree::Country.find_by(iso: 'IN')
|
|
57
|
+
if india
|
|
58
|
+
state_match = india.states.where("LOWER(abbr) = ? OR LOWER(name) = ?", raw_state.downcase, raw_state.downcase).first
|
|
59
|
+
if state_match
|
|
60
|
+
state_name = state_match.name
|
|
61
|
+
elsif state_map[raw_state.upcase]
|
|
62
|
+
state_name = state_map[raw_state.upcase]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Build Full Location String
|
|
67
|
+
parts = []
|
|
68
|
+
parts << city_name if city_name.present?
|
|
69
|
+
parts << dist_name if dist_name.present? && dist_name.downcase != city_name.downcase
|
|
70
|
+
if state_name.present? && state_name.downcase != city_name.downcase && state_name.downcase != dist_name.downcase
|
|
71
|
+
parts << state_name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
location_text = parts.join(', ') if parts.any?
|
|
75
|
+
end
|
|
76
|
+
rescue => e
|
|
77
|
+
Rails.logger.error "Delhivery City Parse Error: #{e.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# --- 2. TAT & Timer Logic ---
|
|
81
|
+
response = client.calculate_tat(source_pin: source_pin, dest_pin: pincode, mode: mode)
|
|
82
|
+
|
|
83
|
+
if response && (response['success'] == true || response['estimated_delivery_date'])
|
|
84
|
+
if response['data'] && response['data']['tat']
|
|
85
|
+
delivery_date = Date.today + response['data']['tat'].to_i.days
|
|
86
|
+
elsif response['estimated_delivery_date']
|
|
87
|
+
delivery_date = Date.parse(response['estimated_delivery_date'])
|
|
88
|
+
else
|
|
89
|
+
delivery_date = Date.today + 5.days
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
date_text = "Delivery by #{delivery_date.strftime("%A, %B %d")}"
|
|
93
|
+
|
|
94
|
+
# Timer Calculation
|
|
95
|
+
cutoff_24 = cutoff_h
|
|
96
|
+
if cutoff_m.upcase == 'PM' && cutoff_h < 12
|
|
97
|
+
cutoff_24 += 12
|
|
98
|
+
elsif cutoff_m.upcase == 'AM' && cutoff_h == 12
|
|
99
|
+
cutoff_24 = 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
now = Time.current
|
|
103
|
+
target_time = now.change(hour: cutoff_24, min: 0, sec: 0)
|
|
104
|
+
target_time += 1.day if now > target_time
|
|
105
|
+
|
|
106
|
+
remaining = (target_time - now).to_i
|
|
107
|
+
timer_text = "#{remaining / 3600} hrs #{(remaining % 3600) / 60} mins"
|
|
108
|
+
|
|
109
|
+
# V3 Standard: Wrap response in a 'data' block
|
|
110
|
+
render json: {
|
|
111
|
+
success: true,
|
|
112
|
+
data: {
|
|
113
|
+
date_text: date_text,
|
|
114
|
+
location_text: location_text,
|
|
115
|
+
timer_text: timer_text
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else
|
|
119
|
+
render json: { success: false, error: "Delivery not available." }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Calculator::Shipping
|
|
3
|
+
class Delhivery < Spree::ShippingCalculator
|
|
4
|
+
|
|
5
|
+
preference :handling_fee, :decimal, default: 0.0
|
|
6
|
+
preference :service_mode, :string, default: 'Surface' # Full Name
|
|
7
|
+
|
|
8
|
+
def self.description
|
|
9
|
+
"Delhivery Live Rate"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def compute_package(package)
|
|
13
|
+
integration = Spree::Integrations::Delhivery.active.first
|
|
14
|
+
return nil unless integration
|
|
15
|
+
|
|
16
|
+
order = package.order
|
|
17
|
+
stock_location = package.stock_location
|
|
18
|
+
return nil unless stock_location&.zipcode && order.ship_address&.zipcode
|
|
19
|
+
|
|
20
|
+
origin_pin = stock_location.zipcode
|
|
21
|
+
dest_pin = order.ship_address.zipcode
|
|
22
|
+
|
|
23
|
+
store_weight_unit = integration.preferred_store_weight_unit.to_s.downcase
|
|
24
|
+
store_dim_unit = integration.preferred_store_dimension_unit.to_s.downcase
|
|
25
|
+
|
|
26
|
+
total_actual_weight_gms = 0.0
|
|
27
|
+
total_volumetric_weight_gms = 0.0
|
|
28
|
+
|
|
29
|
+
package.contents.each do |item|
|
|
30
|
+
variant = item.variant
|
|
31
|
+
qty = item.quantity
|
|
32
|
+
|
|
33
|
+
# Weight to Grams
|
|
34
|
+
w_raw = variant.weight.to_f
|
|
35
|
+
w_gms = case store_weight_unit
|
|
36
|
+
when 'kg', 'kilograms' then w_raw * 1000.0
|
|
37
|
+
when 'lbs', 'pounds' then w_raw * 453.592
|
|
38
|
+
when 'oz', 'ounces' then w_raw * 28.3495
|
|
39
|
+
when 'g', 'grams' then w_raw
|
|
40
|
+
else w_raw * 1000.0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
total_actual_weight_gms += (w_gms * qty)
|
|
44
|
+
|
|
45
|
+
# Dimensions to CM
|
|
46
|
+
l_raw = variant.depth.to_f
|
|
47
|
+
w_raw = variant.width.to_f
|
|
48
|
+
h_raw = variant.height.to_f
|
|
49
|
+
|
|
50
|
+
if l_raw.zero?
|
|
51
|
+
l_raw = (store_dim_unit == 'mm' ? 100.0 : 10.0)
|
|
52
|
+
w_raw = (store_dim_unit == 'mm' ? 100.0 : 10.0)
|
|
53
|
+
h_raw = (store_dim_unit == 'mm' ? 10.0 : 1.0)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
to_cm = ->(val) {
|
|
57
|
+
case store_dim_unit
|
|
58
|
+
when 'mm', 'millimeters' then val / 10.0
|
|
59
|
+
when 'm', 'meters' then val * 100.0
|
|
60
|
+
when 'in', 'inches' then val * 2.54
|
|
61
|
+
when 'cm', 'centimeters' then val
|
|
62
|
+
else val
|
|
63
|
+
end
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
vol_weight_kg = (to_cm.call(l_raw) * to_cm.call(w_raw) * to_cm.call(h_raw)) / 5000.0
|
|
67
|
+
total_volumetric_weight_gms += (vol_weight_kg * 1000.0 * qty)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
chargeable_weight_gms = [total_actual_weight_gms, total_volumetric_weight_gms].max.to_i
|
|
71
|
+
chargeable_weight_gms = 50 if chargeable_weight_gms < 50
|
|
72
|
+
|
|
73
|
+
client = SpreeDelhivery::Client.new
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
# Mode 'Surface'/'Express' passed directly
|
|
77
|
+
mode = preferred_service_mode
|
|
78
|
+
cache_key = "delhivery_rate_#{origin_pin}_#{dest_pin}_#{chargeable_weight_gms}_#{mode}"
|
|
79
|
+
|
|
80
|
+
rate = Rails.cache.fetch(cache_key, expires_in: 15.minutes) do
|
|
81
|
+
client.fetch_shipping_rate(
|
|
82
|
+
source_pin: origin_pin,
|
|
83
|
+
dest_pin: dest_pin,
|
|
84
|
+
weight_gms: chargeable_weight_gms,
|
|
85
|
+
mode: mode
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
rate ? rate + preferred_handling_fee : nil
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
Rails.logger.error "Delhivery Calculator Error: #{e.message}"
|
|
92
|
+
return nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Integrations
|
|
3
|
+
class Delhivery < Spree::Integration
|
|
4
|
+
# Configuration Fields
|
|
5
|
+
preference :api_token, :password
|
|
6
|
+
preference :client_name, :string # "REGULAR" or specific client name
|
|
7
|
+
preference :pickup_location_name, :string # Must match Delhivery Dashboard Warehouse Name EXACTLY
|
|
8
|
+
preference :production_mode, :boolean, default: false
|
|
9
|
+
preference :shipping_mode, :string, default: 'Surface' # Options: Surface, Express
|
|
10
|
+
preference :cod_surcharge_amount, :decimal, default: 0.0
|
|
11
|
+
|
|
12
|
+
# --- NEW UNIT PREFERENCES ---
|
|
13
|
+
preference :store_weight_unit, :string, default: 'kg' # Options: kg, lbs, oz, g
|
|
14
|
+
preference :store_dimension_unit, :string, default: 'cm' # Options: cm, in, m, mm
|
|
15
|
+
|
|
16
|
+
validates :preferred_api_token, presence: true
|
|
17
|
+
validates :preferred_pickup_location_name, presence: true
|
|
18
|
+
|
|
19
|
+
def self.integration_name
|
|
20
|
+
"Delhivery"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# This method is required for the Admin Index page sorting
|
|
24
|
+
def self.integration_group
|
|
25
|
+
"Shipping"
|
|
26
|
+
end
|
|
27
|
+
# --- FIX ENDS HERE ---
|
|
28
|
+
|
|
29
|
+
def self.icon_path
|
|
30
|
+
"integration_icons/delhivery.png" # Make sure this image exists in your assets folder
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 4. Helper to get the token (used in your Client)
|
|
34
|
+
def preferred_api_token
|
|
35
|
+
# You can add logic here to decrypt if you want extra security
|
|
36
|
+
preferences[:api_token]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def api_url
|
|
40
|
+
if preferred_production_mode
|
|
41
|
+
"https://track.delhivery.com"
|
|
42
|
+
else
|
|
43
|
+
"https://staging-express.delhivery.com"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module OrderDecorator
|
|
3
|
+
def self.prepended(base)
|
|
4
|
+
# Hook 1: Add/Remove COD Surcharge for both Rails (confirm) and Next.js (complete) flows
|
|
5
|
+
base.state_machine.before_transition to: [:confirm, :complete], do: :manage_cod_surcharge
|
|
6
|
+
|
|
7
|
+
# Hook 2: Auto-void Delhivery waybills if the order is canceled
|
|
8
|
+
base.state_machine.after_transition to: :canceled, do: :cancel_delhivery_shipments
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def manage_cod_surcharge
|
|
12
|
+
# 1. Check if the customer chose Delhivery COD
|
|
13
|
+
is_cod = payments.valid.any? { |p| p.payment_method&.type == 'Spree::PaymentMethod::DelhiveryCod' }
|
|
14
|
+
|
|
15
|
+
# 2. Fetch your Delhivery configuration
|
|
16
|
+
integration = Spree::Integrations::Delhivery.active.first
|
|
17
|
+
surcharge_amount = integration&.preferred_cod_surcharge_amount.to_f
|
|
18
|
+
|
|
19
|
+
# 3. Apply or Remove the financial adjustment
|
|
20
|
+
if is_cod && surcharge_amount > 0
|
|
21
|
+
# Destroy any old COD fees to prevent duplicate stacking
|
|
22
|
+
adjustments.where(label: 'COD Surcharge').destroy_all
|
|
23
|
+
|
|
24
|
+
# Create the new fee
|
|
25
|
+
adjustments.create!(
|
|
26
|
+
order: self,
|
|
27
|
+
adjustable: self, # Apply to the whole order
|
|
28
|
+
label: 'COD Surcharge',
|
|
29
|
+
amount: surcharge_amount,
|
|
30
|
+
state: 'closed', # Prevents Spree's auto-calculator from zeroing it out
|
|
31
|
+
included: false
|
|
32
|
+
)
|
|
33
|
+
else
|
|
34
|
+
# If they switched back to Prepaid, cleanly remove the fee
|
|
35
|
+
adjustments.where(label: 'COD Surcharge').destroy_all
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Force Spree to recalculate the grand total with the new fee
|
|
39
|
+
updater.update
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def cancel_delhivery_shipments
|
|
45
|
+
shipments.where.not(delhivery_waybill: nil).each do |shipment|
|
|
46
|
+
client = SpreeDelhivery::Client.new
|
|
47
|
+
response = client.cancel_shipment(shipment.delhivery_waybill)
|
|
48
|
+
|
|
49
|
+
if response['status'] == "True" || response['success'] == true
|
|
50
|
+
Rails.logger.info "[Delhivery] Auto-Cancellation Success for #{shipment.number}"
|
|
51
|
+
shipment.update_columns(delhivery_waybill: nil, tracking: nil)
|
|
52
|
+
else
|
|
53
|
+
Rails.logger.error "[Delhivery] Auto-Cancellation FAILED for #{shipment.number}: #{response}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
Rails.logger.error "[Delhivery] System Error during Auto-Cancellation: #{e.message}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Apply the decorator
|
|
63
|
+
Spree::Order.prepend(Spree::OrderDecorator)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module PageBlocks
|
|
3
|
+
module Products
|
|
4
|
+
class DelhiveryEdd < (defined?(Spree::PageBlock) ? Spree::PageBlock : Object)
|
|
5
|
+
if defined?(Spree::PageBlock)
|
|
6
|
+
preference :heading_text, :string, default: 'Estimated Delivery Date'
|
|
7
|
+
preference :placeholder_text, :string, default: 'Enter PIN Code'
|
|
8
|
+
preference :button_text, :string, default: 'Check'
|
|
9
|
+
preference :default_mode, :string, default: 'Surface'
|
|
10
|
+
preference :cutoff_time, :string, default: '14:00'
|
|
11
|
+
preference :cutoff_hour, :string, default: '2'
|
|
12
|
+
preference :cutoff_meridiem, :string, default: 'PM'
|
|
13
|
+
preference :input_border_color, :string, default: '#E2E8F0'
|
|
14
|
+
preference :button_bg_color, :string, default: '#000000'
|
|
15
|
+
preference :button_text_color, :string, default: '#FFFFFF'
|
|
16
|
+
preference :success_color, :string, default: '#10B981'
|
|
17
|
+
preference :error_color, :string, default: '#EF4444'
|
|
18
|
+
|
|
19
|
+
def self.block_name
|
|
20
|
+
"Delhivery EDD Widget"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.display_name
|
|
24
|
+
"Delhivery Delivery Checker"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def icon_name
|
|
28
|
+
"truck-delivery"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render(view_context, locals = {})
|
|
32
|
+
if respond_to?(:available?, true)
|
|
33
|
+
return '' unless available?(locals)
|
|
34
|
+
end
|
|
35
|
+
view_context.render partial: 'spree/page_blocks/products/delhivery_edd/delhivery_edd',
|
|
36
|
+
locals: locals.merge(block: self)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module PageSections
|
|
3
|
+
module ProductDetailsDecorator
|
|
4
|
+
def default_blocks
|
|
5
|
+
# Safely add blocks only if they are loaded into memory
|
|
6
|
+
blocks = super
|
|
7
|
+
blocks << Spree::PageBlocks::Products::RazorpayAffordability.new if defined?(Spree::PageBlocks::Products::RazorpayAffordability)
|
|
8
|
+
blocks << Spree::PageBlocks::Products::DelhiveryEdd.new if defined?(Spree::PageBlocks::Products::DelhiveryEdd)
|
|
9
|
+
blocks
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def available_blocks_to_add
|
|
13
|
+
blocks = super
|
|
14
|
+
blocks << Spree::PageBlocks::Products::RazorpayAffordability if defined?(Spree::PageBlocks::Products::RazorpayAffordability)
|
|
15
|
+
blocks << Spree::PageBlocks::Products::DelhiveryEdd if defined?(Spree::PageBlocks::Products::DelhiveryEdd)
|
|
16
|
+
blocks
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# ONLY prepend if the core Storefront class exists.
|
|
23
|
+
# This sits OUTSIDE the module definition.
|
|
24
|
+
if defined?(Spree::PageSections::ProductDetails)
|
|
25
|
+
Spree::PageSections::ProductDetails.prepend(Spree::PageSections::ProductDetailsDecorator)
|
|
26
|
+
end
|