odoo-addon-delivery-ups-oca 17.0.1.0.0.2__py3-none-any.whl

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.
Files changed (26) hide show
  1. odoo/addons/delivery_ups_oca/README.rst +147 -0
  2. odoo/addons/delivery_ups_oca/__init__.py +3 -0
  3. odoo/addons/delivery_ups_oca/__manifest__.py +26 -0
  4. odoo/addons/delivery_ups_oca/data/stock_package_type_data.xml +120 -0
  5. odoo/addons/delivery_ups_oca/i18n/delivery_ups_oca.pot +399 -0
  6. odoo/addons/delivery_ups_oca/i18n/it.po +471 -0
  7. odoo/addons/delivery_ups_oca/models/__init__.py +5 -0
  8. odoo/addons/delivery_ups_oca/models/delivery_carrier.py +213 -0
  9. odoo/addons/delivery_ups_oca/models/stock_package_type.py +9 -0
  10. odoo/addons/delivery_ups_oca/models/stock_picking.py +14 -0
  11. odoo/addons/delivery_ups_oca/models/ups_request.py +415 -0
  12. odoo/addons/delivery_ups_oca/readme/CONFIGURE.md +17 -0
  13. odoo/addons/delivery_ups_oca/readme/CONTRIBUTORS.md +9 -0
  14. odoo/addons/delivery_ups_oca/readme/DESCRIPTION.md +10 -0
  15. odoo/addons/delivery_ups_oca/readme/ROADMAP.md +2 -0
  16. odoo/addons/delivery_ups_oca/readme/USAGE.md +12 -0
  17. odoo/addons/delivery_ups_oca/static/description/icon.png +0 -0
  18. odoo/addons/delivery_ups_oca/static/description/index.html +498 -0
  19. odoo/addons/delivery_ups_oca/tests/__init__.py +3 -0
  20. odoo/addons/delivery_ups_oca/tests/test_delivery_ups.py +165 -0
  21. odoo/addons/delivery_ups_oca/views/delivery_carrier_view.xml +84 -0
  22. odoo/addons/delivery_ups_oca/views/stock_picking_view.xml +24 -0
  23. odoo_addon_delivery_ups_oca-17.0.1.0.0.2.dist-info/METADATA +166 -0
  24. odoo_addon_delivery_ups_oca-17.0.1.0.0.2.dist-info/RECORD +26 -0
  25. odoo_addon_delivery_ups_oca-17.0.1.0.0.2.dist-info/WHEEL +5 -0
  26. odoo_addon_delivery_ups_oca-17.0.1.0.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ # Copyright 2020 Hunki Enterprises BV
2
+ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3
+ from odoo import fields, models
4
+
5
+
6
+ class PackageType(models.Model):
7
+ _inherit = "stock.package.type"
8
+
9
+ package_carrier_type = fields.Selection(selection_add=[("ups", "UPS")])
@@ -0,0 +1,14 @@
1
+ # Copyright 2022 Tecnativa - Víctor Martínez
2
+ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
3
+ from odoo import models
4
+
5
+
6
+ class StockPicking(models.Model):
7
+ _inherit = "stock.picking"
8
+
9
+ def ups_get_label(self):
10
+ self.ensure_one()
11
+ tracking_ref = self.carrier_tracking_ref
12
+ if self.delivery_type != "ups" or not tracking_ref:
13
+ return
14
+ return self.carrier_id.ups_get_label(tracking_ref)
@@ -0,0 +1,415 @@
1
+ # Copyright 2020 Hunki Enterprises BV
2
+ # Copyright 2021 Tecnativa - Víctor Martínez
3
+ # Copyright 2024 Sygel - Manuel Regidor
4
+ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
5
+
6
+ import datetime
7
+ import logging
8
+
9
+ import requests
10
+
11
+ from odoo import _
12
+ from odoo.exceptions import UserError
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+
17
+ class UpsRequest:
18
+ def __init__(self, carrier):
19
+ self.carrier = carrier
20
+ self.default_packaging_id = self.carrier.ups_default_packaging_id
21
+ self.use_packages_from_picking = self.carrier.ups_use_packages_from_picking
22
+ self.shipper_number = self.carrier.ups_shipper_number
23
+ self.service_code = self.carrier.ups_service_code
24
+ self.file_format = self.carrier.ups_file_format
25
+ self.package_dimension_code = self.carrier.ups_package_dimension_code
26
+ self.package_weight_code = self.carrier.ups_package_weight_code
27
+ self.transaction_src = "Odoo (%s)" % self.carrier.name
28
+ self.client_id = self.carrier.ups_client_id
29
+ self.client_secret = self.carrier.ups_client_secret
30
+ self.token = self.carrier.ups_token
31
+ self.token_expiration_date = self.carrier.ups_token_expiration_date
32
+ self.url = "https://wwwcie.ups.com"
33
+ if self.carrier.prod_environment:
34
+ self.url = "https://onlinetools.ups.com"
35
+
36
+ def _raise_for_status(self, status, skip_errors=True):
37
+ errors = status.get("response", {}).get("errors")
38
+ if errors:
39
+ msg = _("Sending to UPS: {}").format(
40
+ "\n".join("{code} {message}".format(**error) for error in errors),
41
+ )
42
+ if skip_errors:
43
+ _logger.info(msg)
44
+ else:
45
+ raise UserError(msg)
46
+
47
+ def _send_request(
48
+ self, url, json=None, data=None, headers=None, method="post", auth=None
49
+ ):
50
+ return getattr(requests, method)(
51
+ url, data=data, json=json, headers=headers, auth=auth
52
+ )
53
+
54
+ def _get_new_token(self):
55
+ if not (self.client_id and self.client_secret):
56
+ raise UserError(
57
+ _(
58
+ "Both Client ID and Client Secret"
59
+ " must be set in UPS delivery carriers."
60
+ )
61
+ )
62
+ url = "%s/security/v1/oauth/token" % self.url
63
+ headers = {"x-merchant-id": self.client_id}
64
+ data = {"grant_type": "client_credentials"}
65
+ status = self._send_request(
66
+ url, data=data, headers=headers, auth=(self.client_id, self.client_secret)
67
+ )
68
+ status = status.json()
69
+ self._raise_for_status(status, False)
70
+ token = status.get("access_token")
71
+ self.token = token
72
+ self.carrier.ups_token = token
73
+ self.carrier.ups_token_expiration_date = (
74
+ datetime.datetime.now()
75
+ + datetime.timedelta(seconds=int(status.get("expires_in")))
76
+ )
77
+
78
+ def _process_reply(
79
+ self,
80
+ url,
81
+ json=None,
82
+ data=None,
83
+ method="post",
84
+ headers_extra=None,
85
+ ):
86
+ if (
87
+ not self.token
88
+ or not self.token_expiration_date
89
+ or (self.token_expiration_date <= datetime.datetime.now())
90
+ ):
91
+ self._get_new_token()
92
+ data = data or {}
93
+ headers = {
94
+ "Authorization": f"Bearer {self.token}",
95
+ }
96
+ if headers_extra:
97
+ headers = {**headers, **headers_extra}
98
+ status = self._send_request(url, json, data, headers, method)
99
+ # Generate a new token
100
+ if status.status_code == 401:
101
+ self._get_new_token()
102
+ status = self._send_request(url, json, data, headers, method)
103
+ status = status.json()
104
+ ups_last_request = f"URL: {self.url}\nData: {data}"
105
+ self.carrier.log_xml(ups_last_request, "ups_last_request")
106
+ self.carrier.log_xml(status or "", "ups_last_response")
107
+ return status
108
+
109
+ def _quant_package_data_from_picking(self, package, picking, is_package=False):
110
+ NumOfPieces = picking.number_of_packages
111
+ PackageWeight = picking.shipping_weight
112
+ if is_package:
113
+ NumOfPieces = sum(package.mapped("quant_ids.quantity"))
114
+ PackageWeight = max(package.shipping_weight, package.weight)
115
+ package = package.package_type_id
116
+ return {
117
+ "Description": package.name,
118
+ "NumOfPieces": str(NumOfPieces),
119
+ "Packaging": {
120
+ "Code": package.shipper_package_code,
121
+ "Description": package.name,
122
+ },
123
+ "Dimensions": {
124
+ "UnitOfMeasurement": {"Code": self.package_dimension_code},
125
+ "Length": str(package.packaging_length),
126
+ "Width": str(package.width),
127
+ "Height": str(package.height),
128
+ },
129
+ "PackageWeight": {
130
+ "UnitOfMeasurement": {"Code": self.package_weight_code},
131
+ "Weight": str(PackageWeight),
132
+ },
133
+ }
134
+
135
+ def _partner_to_shipping_data(self, partner, **kwargs):
136
+ """Return a dict describing a partner for the shipping request"""
137
+ return dict(
138
+ **kwargs,
139
+ Name=(partner.parent_id or partner).name,
140
+ AttentionName=partner.name,
141
+ TaxIdentificationNumber=partner.vat,
142
+ Phone=dict(Number=partner.phone or partner.mobile),
143
+ EMailAddress=partner.email,
144
+ Address=dict(
145
+ AddressLine=[partner.street, partner.street2 or ""],
146
+ City=partner.city,
147
+ StateProvinceCode=partner.state_id.code,
148
+ PostalCode=partner.zip,
149
+ CountryCode=partner.country_id.code,
150
+ ),
151
+ )
152
+
153
+ def _label_data(self):
154
+ res = {"LabelImageFormat": {"Code": self.file_format}}
155
+ # According to documentation, we need to specify sizes in some formats
156
+ if self.file_format != "GIF":
157
+ res["LabelStockSize"] = {"Height": "6", "Width": "4"}
158
+ return res
159
+
160
+ def _prepare_create_shipping(self, picking):
161
+ """Return a dict that can be passed to the shipping endpoint of the UPS API"""
162
+ if self.use_packages_from_picking and picking.package_ids:
163
+ # modelo: stock.quant.package
164
+ packages = [
165
+ self._quant_package_data_from_picking(package, picking, True)
166
+ for package in picking.package_ids
167
+ ]
168
+ else:
169
+ # modelo: stock.package.type
170
+ packages = []
171
+ package_info = self._quant_package_data_from_picking(
172
+ self.default_packaging_id, picking, False
173
+ )
174
+ package_weight = round(
175
+ (picking.shipping_weight / picking.number_of_packages), 2
176
+ )
177
+ for i in range(0, picking.number_of_packages):
178
+ package_item = package_info.copy()
179
+ package_name = f"{picking.name} ({i+1})"
180
+ package_item["Description"] = package_name
181
+ package_item["NumOfPieces"] = "1"
182
+ package_item["Packaging"]["Description"] = package_name
183
+ package_item["PackageWeight"]["Weight"] = str(package_weight)
184
+ packages.append(package_item)
185
+ vals = {
186
+ "ShipmentRequest": {
187
+ "Shipment": {
188
+ "Description": picking.name,
189
+ "Shipper": self._partner_to_shipping_data(
190
+ partner=picking.company_id.partner_id,
191
+ ShipperNumber=self.shipper_number,
192
+ ),
193
+ "ShipTo": self._partner_to_shipping_data(picking.partner_id),
194
+ "ShipFrom": self._partner_to_shipping_data(
195
+ picking.picking_type_id.warehouse_id.partner_id
196
+ or picking.company_id.partner_id
197
+ ),
198
+ "PaymentInformation": {
199
+ "ShipmentCharge": {
200
+ "Type": "01",
201
+ "BillShipper": {
202
+ # we ignore the alternatives paying per credit card or
203
+ # paypal for now
204
+ "AccountNumber": self.shipper_number,
205
+ },
206
+ }
207
+ },
208
+ "Service": {"Code": self.service_code},
209
+ "Package": packages,
210
+ },
211
+ "LabelSpecification": self._label_data(),
212
+ }
213
+ }
214
+ if picking.carrier_id.ups_cash_on_delivery and picking.sale_id:
215
+ vals["ShipmentRequest"]["Shipment"]["ShipmentServiceOptions"] = (
216
+ {
217
+ "COD": {
218
+ "CODFundsCode": picking.carrier_id.ups_cod_funds_code,
219
+ "CODAmount": {
220
+ "CurrencyCode": picking.sale_id.currency_id.name,
221
+ "MonetaryValue": str(picking.sale_id.amount_total),
222
+ },
223
+ }
224
+ },
225
+ )
226
+ return vals
227
+
228
+ def _send_shipping(self, picking):
229
+ status = self._process_reply(
230
+ url="%s/api/shipments/v1/ship" % self.url,
231
+ json=self._prepare_create_shipping(picking),
232
+ )
233
+ self._raise_for_status(status, False)
234
+ res = status["ShipmentResponse"]["ShipmentResults"]
235
+ PackageResults = res["PackageResults"]
236
+ labels = []
237
+ if isinstance(PackageResults, dict):
238
+ labels.append(
239
+ {
240
+ "tracking_ref": PackageResults["TrackingNumber"],
241
+ "format_code": PackageResults["ShippingLabel"]["ImageFormat"][
242
+ "Code"
243
+ ],
244
+ "datas": PackageResults["ShippingLabel"]["GraphicImage"],
245
+ }
246
+ )
247
+ if isinstance(PackageResults, list):
248
+ for label in PackageResults:
249
+ labels.append(
250
+ {
251
+ "tracking_ref": label["TrackingNumber"],
252
+ "format_code": label["ShippingLabel"]["ImageFormat"]["Code"],
253
+ "datas": label["ShippingLabel"]["GraphicImage"],
254
+ }
255
+ )
256
+ return {
257
+ "price": res["ShipmentCharges"]["TotalCharges"],
258
+ "ShipmentIdentificationNumber": res["ShipmentIdentificationNumber"],
259
+ "labels": labels,
260
+ }
261
+
262
+ def _quant_package_data_from_order(self, order):
263
+ PackageWeight = 0
264
+ for line in order.order_line.filtered(
265
+ lambda x: x.product_id and x.product_id.weight > 0
266
+ ):
267
+ PackageWeight += line.product_id.weight * line.product_uom_qty
268
+ return {
269
+ "PackagingType": {"Code": self.default_packaging_id.shipper_package_code},
270
+ "Dimensions": {
271
+ "UnitOfMeasurement": {"Code": self.package_dimension_code},
272
+ "Length": str(self.default_packaging_id.packaging_length),
273
+ "Width": str(self.default_packaging_id.width),
274
+ "Height": str(self.default_packaging_id.height),
275
+ },
276
+ "PackageWeight": {
277
+ "UnitOfMeasurement": {"Code": self.package_weight_code},
278
+ "Weight": str(PackageWeight),
279
+ },
280
+ }
281
+
282
+ def _prepare_rate_shipment(self, order):
283
+ packages = [self._quant_package_data_from_order(order)]
284
+ return {
285
+ "RateRequest": {
286
+ "Shipment": {
287
+ "Shipper": self._partner_to_shipping_data(
288
+ partner=order.company_id.partner_id,
289
+ ShipperNumber=self.shipper_number,
290
+ ),
291
+ "ShipTo": self._partner_to_shipping_data(order.partner_shipping_id),
292
+ "ShipFrom": self._partner_to_shipping_data(
293
+ order.warehouse_id.partner_id or order.company_id.partner_id
294
+ ),
295
+ "Service": {"Code": self.service_code},
296
+ "Package": packages,
297
+ }
298
+ }
299
+ }
300
+
301
+ def _rate_shipment(self, order, skip_errors=False):
302
+ status = self._process_reply(
303
+ url="%s/api/rating/v1/Rate" % self.url,
304
+ json=self._prepare_rate_shipment(order),
305
+ )
306
+ self._raise_for_status(status, skip_errors)
307
+ return status
308
+
309
+ def rate_shipment(self, order):
310
+ status = self._rate_shipment(order)
311
+ return status["RateResponse"]["RatedShipment"]["TotalCharges"]
312
+
313
+ def _prepare_shipping_label(self, carrier_tracking_ref):
314
+ return {
315
+ "LabelRecoveryRequest": {
316
+ "LabelSpecification": self._label_data(),
317
+ "TrackingNumber": carrier_tracking_ref,
318
+ }
319
+ }
320
+
321
+ def shipping_label(self, carrier_tracking_ref):
322
+ status = self._process_reply(
323
+ url="%s/api/labels/v1/recovery" % self.url,
324
+ json=self._prepare_shipping_label(carrier_tracking_ref),
325
+ )
326
+ self._raise_for_status(status, False)
327
+ labels = []
328
+ labels_data = status["LabelRecoveryResponse"]["LabelResults"]
329
+ if isinstance(labels_data, dict):
330
+ labels.append(
331
+ {
332
+ "tracking_ref": labels_data["TrackingNumber"],
333
+ "format_code": labels_data["LabelImage"]["LabelImageFormat"][
334
+ "Code"
335
+ ],
336
+ "datas": labels_data["LabelImage"]["GraphicImage"],
337
+ }
338
+ )
339
+ elif isinstance(labels_data, list):
340
+ for label in labels_data:
341
+ labels.append(
342
+ {
343
+ "tracking_ref": label["TrackingNumber"],
344
+ "format_code": label["LabelImage"]["LabelImageFormat"]["Code"],
345
+ "datas": label["LabelImage"]["GraphicImage"],
346
+ }
347
+ )
348
+
349
+ return labels
350
+
351
+ def cancel_shipment(self, picking):
352
+ url = "%s/api/shipments/v1/void/cancel" % self.url
353
+ url = f"{url}/{picking.carrier_tracking_ref}"
354
+ status = self._process_reply(url=url, method="delete")
355
+ self._raise_for_status(status, False)
356
+ return True
357
+
358
+ def tracking_state_update(self, picking):
359
+ static_states = {
360
+ "I": "in_transit",
361
+ "D": "customer_delivered",
362
+ "E": "incidence",
363
+ "P": "customer_delivered",
364
+ "M": "in_transit",
365
+ }
366
+ status = self._process_reply(
367
+ url=f"{self.url}/api/track/v1/details/{picking.carrier_tracking_ref}",
368
+ method="get",
369
+ headers_extra={
370
+ "transId": f"{datetime.datetime.now().timestamp()}",
371
+ "transactionSrc": f"{picking.company_id.name} - Odoo",
372
+ },
373
+ )
374
+ self._raise_for_status(status, False)
375
+ states_list = []
376
+ delivery_state = "incidence"
377
+ try:
378
+ shipment = status["trackResponse"]["shipment"][0]
379
+ if not shipment.get("warnings"):
380
+ for activity in shipment["package"][0]["activity"]:
381
+ states_list.append(
382
+ "{} - {}".format(
383
+ datetime.datetime.strptime(
384
+ "{}{}".format(
385
+ activity.get("date"), activity.get("time")
386
+ ),
387
+ "%Y%m%d%H%M%S",
388
+ ),
389
+ activity.get("status").get("description"),
390
+ )
391
+ )
392
+ if shipment["package"][0]["activity"]:
393
+ delivery_state = static_states.get(
394
+ shipment["package"][0]["activity"][0]["status"]["type"],
395
+ "incidence",
396
+ )
397
+ else:
398
+ for warning in shipment.get("warnings"):
399
+ states_list.append(
400
+ _("{date} - Warning: {warn}").format(
401
+ date=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
402
+ warn=warning.get("message"),
403
+ )
404
+ )
405
+
406
+ except Exception:
407
+ states_list.append(
408
+ _("{} - Error retrieving the tracking information.").format(
409
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
410
+ )
411
+ )
412
+ return {
413
+ "delivery_state": delivery_state,
414
+ "tracking_state_history": "\n".join(states_list),
415
+ }
@@ -0,0 +1,17 @@
1
+ To configure this module, you need to:
2
+
3
+ 1. Add a Shipping Method with Provider ``UPS`` and fill in your UPS credentials
4
+ (Client ID and Client Secret)
5
+ 2. Configure in Odoo all required fields of the UPS tab with your
6
+ account data <https://wwwapps.ups.com/ppc/ppc.html> (Shipper number,
7
+ Default Packaging, Package Dimension Code, Package Weight Code and
8
+ File Format).
9
+ 3. If yo have "Tracking state update sync" checked all delivery orders
10
+ state check will be done querying UPS services.
11
+ 4. It is possible to create a UPS carrier for cash on delivery parcels.
12
+ Select the `ups` delivery type and check the "Cash on Delivery"
13
+ checkbox under the "UPS" tab. It is required to select the "UPS COD
14
+ Funds Code" when the "Cash on Delivery" option is selected.
15
+
16
+ **NOTE** You need to add an APP from <https://developer.ups.com/> for
17
+ using the webservice.
@@ -0,0 +1,9 @@
1
+ - Holger Brunn \<<mail@hunki-enterprises.nl>\>
2
+ (<https://hunki-enterprises.nl>)
3
+ - [Tecnativa](https://www.tecnativa.com):
4
+ - Víctor Martínez
5
+ - Pedro M. Baeza
6
+ - [ForgeFlow](https://www.forgeflow.com):
7
+ - Jordi Ballester
8
+ - [Sygel](https://www.sygel.es):
9
+ - Manuel Regidor
@@ -0,0 +1,10 @@
1
+ This module adds [UPS](https://ups.com) to the available carriers.
2
+
3
+ It allows you to register shippings, generate labels, get rates from
4
+ order, read shipping states and cancel shipments using UPS webservice,
5
+ so no need of exchanging any kind of file.
6
+
7
+ When a sales order is created in Odoo and the UPS carrier is assigned,
8
+ the shipping price that will be obtained will be the price that the UPS
9
+ webservice estimates according to the order information (address and
10
+ products).
@@ -0,0 +1,2 @@
1
+ - Support international forms
2
+ - Support package service options
@@ -0,0 +1,12 @@
1
+ You have to set the created shipping method in the delivery order to
2
+ ship:
3
+
4
+ - When the picking is 'Transferred', a *Create Shipping Label* button
5
+ appears. Just click on it, and if all went well, the label will be
6
+ 'attached'.
7
+ - If the shipment creation process fails, a validation error will appear
8
+ displaying UPS error.
9
+ - When the delivery order is cancelled, it's automatically cancelled too
10
+ in UPS.
11
+ - If you have "Tracking state update sync" checked in the shipping
12
+ method, a periodical state check will be done querying UPS services.