diff --git a/delivery_gso/__init__.py b/delivery_gso/__init__.py new file mode 100644 index 00000000..09434554 --- /dev/null +++ b/delivery_gso/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models diff --git a/delivery_gso/__manifest__.py b/delivery_gso/__manifest__.py new file mode 100644 index 00000000..e1923d56 --- /dev/null +++ b/delivery_gso/__manifest__.py @@ -0,0 +1,26 @@ +{ + 'name': 'Golden State Overnight (gso.com) Shipping', + 'summary': 'Send your shippings through gso.com and track them online.', + 'version': '15.0.1.0.0', + 'author': "Hibou Corp.", + 'category': 'Warehouse', + 'license': 'OPL-1', + 'images': [], + 'website': "https://hibou.io", + 'description': """ +Golden State Overnight (gso.com) Shipping +========================================= + +* Provides estimates on shipping costs through gso.com. +* Send your shippings through gso.com and allows tracking of packages. +""", + 'depends': [ + 'delivery_hibou', + ], + 'demo': [], + 'data': [ + 'views/delivery_gso_view.xml', + ], + 'auto_install': False, + 'installable': True, +} diff --git a/delivery_gso/models/__init__.py b/delivery_gso/models/__init__.py new file mode 100644 index 00000000..c9a65a9e --- /dev/null +++ b/delivery_gso/models/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import delivery_gso diff --git a/delivery_gso/models/delivery_gso.py b/delivery_gso/models/delivery_gso.py new file mode 100644 index 00000000..04f55595 --- /dev/null +++ b/delivery_gso/models/delivery_gso.py @@ -0,0 +1,455 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +import pytz +from math import ceil +from base64 import b64decode +from requests import HTTPError +from hashlib import sha1 + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError, UserError + +from .requests_gso import GSORequest +import logging +_logger = logging.getLogger(__name__) + +GSO_TZ = 'PST8PDT' + + +def inline_b64decode(data): + try: + return b64decode(data) + except: + return '' + + +class StockPackageType(models.Model): + _inherit = 'stock.package.type' + + package_carrier_type = fields.Selection(selection_add=[('gso', 'gso.com')], ondelete={'gso': 'set default'}) + + +class ProviderGSO(models.Model): + _inherit = 'delivery.carrier' + + delivery_type = fields.Selection(selection_add=[('gso', 'gso.com')], ondelete={'gso': 'cascade'}) + gso_username = fields.Char(string='gso.com Username', groups='base.group_system') + gso_password = fields.Char(string='gso.com Password', groups='base.group_system') + gso_account_number = fields.Char(string='gso.com Account Number', groups='base.group_system') + gso_default_packaging_id = fields.Many2one('stock.package.type', string='Default Package Type') + # For service type, SAM, SPM, and SEV require authorized accounts. + gso_service_type = fields.Selection([('PDS', 'Priority Overnight'), + ('EPS', 'Early Priority Overnight'), + ('NPS', 'Noon Priority Overnight'), + ('SDS', 'Saturday Delivery'), + ('ESS', 'Early Saturday Delivery'), + ('CPS', 'GSO Ground'), + ('SAM', 'AM Select (8A-12P) Delivery Window'), + ('SPM', 'PM Select (12P-4P) Delivery Window'), + ('SEV', 'Evening Select (4P-8P) Delivery Window'), + ], + string="Service Type", default="CPS", help="Service Type determines speed of delivery") + gso_image_type = fields.Selection([('NO_LABEL', 'No Label'), + ('PAPER_LABEL', 'Paper Label'), + ('ZPL_SHORT_LABEL', 'Short Label'), + ('ZPL_LONG_LABEL', 'Long label'), + ], + string="Image Type", default="ZPL_SHORT_LABEL", help="Image Type is the type of Label to use") + + def _get_gso_service(self): + return GSORequest(self.prod_environment, + self.gso_username, + self.gso_password, + self.gso_account_number) + + def _gso_make_ship_address(self, partner): + # Addresses look like + # { + # 'ShipToCompany': '', + # 'ShipToAttention': '', + # 'ShipToPhone': '', + # 'ShipToEmail': '', + # 'DeliveryAddress1': '', + # 'DeliveryAddress2': '', + # 'DeliveryCity': '', + # 'DeliveryState': '', + # 'DeliveryZip': '', + # } + address = {} + # ShipToCompany is required. ShipToAttention which is a person is not. + if partner.name and not partner.parent_id: + address['ShipToCompany'] = partner.name + if partner.name and partner.parent_id: + address['ShipToCompany'] = partner.parent_id.name # or partner.parent_id.id.name ?? + address['ShipToAttention'] = partner.name + + if partner.phone: + address['ShipToPhone'] = partner.phone + if partner.email: + address['ShipToEmail'] = partner.email + if partner.street: + address['DeliveryAddress1'] = partner.street + if partner.street2: + address['DeliveryAddress2'] = partner.street2 + if partner.city: + address['DeliveryCity'] = partner.city + if partner.state_id: + address['DeliveryState'] = partner.state_id.code + if partner.zip: + address['DeliveryZip'] = partner.zip + + return address + + def _gso_make_shipper_address(self, warehouse, company): + # Addresses look like + # { + # 'ShipperCompany': '', + # 'ShipperContact': '', + # 'ShipperPhone': '', + # 'ShipperEmail': '', + # 'PickupAddress1': '', + # 'PickupAddress2': '', + # 'PickupCity': '', + # 'PickupState': '', + # 'PickupZip': '', + # } + address = {} + if company.name and not company.parent_id: + address['ShipperCompany'] = company.name + if company.name and company.parent_id: + address['ShipperCompany'] = company.parent_id.name + address['ShipperContact'] = company.name + + if warehouse.phone: + address['ShipperPhone'] = warehouse.phone + if warehouse.email: + address['ShipperEmail'] = warehouse.email + if warehouse.street: + address['PickupAddress1'] = warehouse.street + if warehouse.street2: + address['PickupAddress2'] = warehouse.street2 + if warehouse.city: + address['PickupCity'] = warehouse.city + if warehouse.state_id: + address['PickupState'] = warehouse.state_id.code + if warehouse.zip: + address['PickupZip'] = warehouse.zip + + return address + + def _gso_create_tracking_number(self, identifier): + # Override for a more 'customized' tracking number + # Expects a self.sudo() + if not identifier: + identifier = fields.Datetime.now() # string in Odoo 11 + salt = self.env['ir.config_parameter'].sudo().get_param('database.secret') + sha = sha1((identifier + salt).encode()).hexdigest() + return sha[:20] + + def _gso_get_package_dimensions(self, package=None): + if not package: + package_type = self.gso_default_packaging_id + else: + package_type = package.packaging_id + length_uom = self.env['product.template']._get_length_uom_id_from_ir_config_parameter() + if length_uom.name == 'ft': + return {'Length': round(package_type.packaging_length / 12.0), 'Width': round(package_type.width / 12.0), 'Height': round(package_type.height / 12.0)} + elif length_uom.name == 'mm': + return {'Length': round(package_type.packaging_length * 0.0393701), 'Width': round(package_type.width * 0.0393701), 'Height': round(package_type.height * 0.0393701)} + return {'Length': package_type.packaging_length, 'Width': package_type.width, 'Height': package_type.height} + + def _gso_convert_weight(self, weight_in_db): + weight_uom = self.env['product.template']._get_weight_uom_id_from_ir_config_parameter() + if weight_uom.name == 'kg': + weight_in_lb = weight_in_db / 0.45359237 + else: + # assume lbs + weight_in_lb = weight_in_db + # If less than 8 oz... + if weight_in_lb < 0.5: + return 0 + else: + # Round up to nearest lb + return int(ceil(weight_in_lb)) + + def gso_send_shipping(self, pickings): + res = [] + sudoself = self.sudo() + service = sudoself._get_gso_service() + + for picking in pickings: + company = self.get_shipper_company(picking=picking) + from_ = self.get_shipper_warehouse(picking=picking) + to = self.get_recipient(picking=picking) + address_type = 'B' if "company" in (to.company_type, to.parent_id.company_type) else 'R' + + request_body = { + 'AccountNumber': sudoself.gso_account_number, + 'Shipment': { + 'ServiceCode': sudoself.gso_service_type, + 'ShipmentLabelType': sudoself.gso_image_type, + 'SignatureCode': 'SIG_NOT_REQD', + 'DeliveryAddressType': address_type, + # 'ShipDate': fields.Date.today(), # safer not to send in case you want to ship on a weekend + }, + } + request_body['Shipment'].update(self._gso_make_shipper_address(from_, company)) + request_body['Shipment'].update(self._gso_make_ship_address(to)) + + # Automatic insurance at $100.0 + insurance_value = sudoself.get_insurance_value(picking=picking) + if insurance_value: + request_body['Shipment']['SignatureCode'] = 'SIG_REQD' + if insurance_value > 100.0: + # Documentation says to set DeclaredValue ONLY if over $100.00 + request_body['Shipment']['DeclaredValue'] = insurance_value + + cost = 0.0 + labels = { + 'thermal': [], + 'paper': [], + } + picking_packages = picking.package_ids + package_carriers = picking_packages.mapped('carrier_id') + if package_carriers: + # only ship ours + picking_packages = picking_packages.filtered(lambda p: p.carrier_id == self and not p.carrier_tracking_ref) + + if picking_packages: + # Every package will be a transaction + for package in picking_packages: + request_body['Shipment']['Weight'] = self._gso_convert_weight(package.shipping_weight) + request_body['Shipment'].update(self._gso_get_package_dimensions(package)) + request_body['Shipment']['ShipmentReference'] = package.name + request_body['Shipment']['TrackingNumber'] = self._gso_create_tracking_number(package.name) + try: + response = service.post_shipment(request_body) + + if response.get('ThermalLabel'): + labels['thermal'].append((response['TrackingNumber'], response['ThermalLabel'])) + elif response.get('PaperLabel'): + labels['paper'].append((response['TrackingNumber'], response['PaperLabel'])) + + if response.get('ShipmentCharges', {}).get('TotalCharge'): + cost += response['ShipmentCharges']['TotalCharge'] + except HTTPError as e: + raise ValidationError(e) + elif not package_carriers: + # ship the whole picking + request_body['Shipment']['Weight'] = self._gso_convert_weight(picking.shipping_weight) + request_body['Shipment'].update(self._gso_get_package_dimensions()) + request_body['Shipment']['ShipmentReference'] = picking.name + request_body['Shipment']['TrackingNumber'] = self._gso_create_tracking_number(picking.name) + try: + response = service.post_shipment(request_body) + + if response.get('ThermalLabel'): + labels['thermal'].append((response['TrackingNumber'], response['ThermalLabel'])) + elif response.get('PaperLabel'): + labels['paper'].append((response['TrackingNumber'], response['PaperLabel'])) + + if response.get('ShipmentCharges', {}).get('TotalCharge'): + cost += response['ShipmentCharges']['TotalCharge'] + except HTTPError as e: + raise ValidationError(e) + else: + continue + + # Handle results + trackings = [l[0] for l in labels['thermal']] + [l[0] for l in labels['paper']] + carrier_tracking_ref = ','.join(trackings) + + logmessage = _("Shipment created into GSO
" + "Tracking Numbers: %s") % (carrier_tracking_ref, ) + attachments = [] + if labels['thermal']: + attachments += [('LabelGSO-%s.zpl' % (l[0], ), l[1]) for l in labels['thermal']] + if labels['paper']: + # paper labels re-encoded base64 + attachments += [('LabelGSO-%s.png' % (l[0], ), inline_b64decode(l[1])) for l in labels['paper']] + picking.message_post(body=logmessage, attachments=attachments) + shipping_data = {'exact_price': cost, + 'tracking_number': carrier_tracking_ref} + res.append(shipping_data) + return res + + def gso_cancel_shipment(self, picking): + sudoself = self.sudo() + service = sudoself._get_gso_service() + try: + request_body = { + 'AccountNumber': sudoself.gso_account_number, + } + for tracking in picking.carrier_tracking_ref.split(','): + request_body['TrackingNumber'] = tracking + __ = service.delete_shipment(request_body) + except HTTPError as e: + raise ValidationError(e) + picking.message_post(body=_('Shipment N° %s has been cancelled') % (picking.carrier_tracking_ref, )) + picking.write({'carrier_tracking_ref': '', 'carrier_price': 0.0}) + + def gso_rate_shipment(self, order): + sudoself = self.sudo() + service = sudoself._get_gso_service() + from_ = sudoself.get_shipper_warehouse(order=order) + to = sudoself.get_recipient(order=order) + address_type = 'B' if "company" in (to.company_type, to.parent_id.company_type) else 'R' + + est_weight_value = self._gso_convert_weight( + sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0) + + date_planned = None + if self.env.context.get('date_planned'): + date_planned = self.env.context.get('date_planned') + + ship_date_utc = fields.Datetime.from_string(date_planned if date_planned else fields.Datetime.now()) + ship_date_utc = ship_date_utc.replace(tzinfo=pytz.utc) + ship_date_gso = ship_date_utc.astimezone(pytz.timezone(GSO_TZ)) + ship_date_gso = fields.Datetime.to_string(ship_date_gso) + + request_body = { + 'AccountNumber': sudoself.gso_account_number, + 'OriginZip': from_.zip, + 'DestinationZip': to.zip, + 'ShipDate': ship_date_gso, + 'PackageDimension': self._gso_get_package_dimensions(), + 'PackageWeight': est_weight_value, + 'DeliveryAddressType': address_type, + } + + result = service.get_rates_and_transit_time(request_body) + + delivery = list(filter(lambda d: d['ServiceCode'] == sudoself.gso_service_type, result['DeliveryServiceTypes'])) + if delivery: + delivery = delivery[0] + delivery_date_gso = delivery['DeliveryDate'].replace('T', ' ') + delivery_date_gso = fields.Datetime.from_string(delivery_date_gso) + delivery_date_gso = delivery_date_gso.replace(tzinfo=pytz.timezone(GSO_TZ)) + delivery_date_utc = delivery_date_gso.astimezone(pytz.utc) + delivery_date_utc = fields.Datetime.to_string(delivery_date_utc) + price = delivery.get('ShipmentCharges', {}).get('TotalCharge', 0.0) + return { + 'success': True, + 'price': price, + 'error_message': False, + 'date_delivered': delivery_date_utc, + 'warning_message': _('TotalCharge not found.') if price == 0.0 else False, + } + + raise Exception() + return { + 'success': False, + 'price': 0.0, + 'error_message': _('Delivery Method not found in result'), + 'warning_message': False, + } + + def gso_get_tracking_link(self, pickings): + # No way to get a link specifically as their site only allows POST into tracking form. + res = [] + for _ in pickings: + res.append('https://www.gso.com/Tracking') + return res + + def gso_rate_shipment_multi(self, order=None, picking=None, packages=None): + if not packages: + return self._gso_rate_shipment_multi_package(order=order, picking=picking) + else: + rates = [] + for package in packages: + rates += self._gso_rate_shipment_multi_package(order=order, picking=picking, package=package) + return rates + + def _gso_rate_shipment_multi_package(self, order=None, picking=None, package=None): + sudoself = self.sudo() + try: + service = sudoself._get_gso_service() + except HTTPError as e: + _logger.error(e) + return [{ + 'success': False, + 'price': 0.0, + 'error_message': _('GSO web service returned an error. ' + str(e)), + 'warning_message': False, + }] + + from_ = sudoself.get_shipper_warehouse(order=order, picking=picking) + to = sudoself.get_recipient(order=order, picking=picking) + address_type = 'B' if bool(to.is_company or to.parent_id.is_company) else 'R' + package_dimensions = self._gso_get_package_dimensions(package=package) + + date_planned = fields.Datetime.now() + if self.env.context.get('date_planned'): + date_planned = self.env.context.get('date_planned') + + ship_date_utc = fields.Datetime.from_string(date_planned if date_planned else fields.Datetime.now()) + ship_date_utc = ship_date_utc.replace(tzinfo=pytz.utc) + ship_date_gso = ship_date_utc.astimezone(pytz.timezone(GSO_TZ)) + ship_date_gso = fields.Datetime.to_string(ship_date_gso) + + if order: + est_weight_value = self._gso_convert_weight( + sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0) + elif not package: + est_weight_value = self._gso_convert_weight(picking.shipping_weight) + else: + est_weight_value = package.shipping_weight or package.weight + + request_body = { + 'AccountNumber': sudoself.gso_account_number, + 'OriginZip': from_.zip, + 'DestinationZip': to.zip, + 'ShipDate': ship_date_gso, + 'PackageDimension': package_dimensions, + 'PackageWeight': est_weight_value, + 'DeliveryAddressType': address_type, + } + + try: + result = service.get_rates_and_transit_time(request_body) + # _logger.warn('GSO result:\n%s' % result) + except HTTPError as e: + _logger.error(e) + return [{ + 'success': False, + 'price': 0.0, + 'error_message': _('GSO web service returned an error.'), + 'warning_message': False, + }] + + # delivery = list(filter(lambda d: d['ServiceCode'] == sudoself.gso_service_type, result['DeliveryServiceTypes'])) + # if delivery: + rates = [] + for delivery in result['DeliveryServiceTypes']: + delivery_date_gso = delivery['DeliveryDate'].replace('T', ' ') + delivery_date_gso = fields.Datetime.from_string(delivery_date_gso) + delivery_date_gso = delivery_date_gso.replace(tzinfo=pytz.timezone(GSO_TZ)) + delivery_date_utc = delivery_date_gso.astimezone(pytz.utc) + delivery_date_utc = fields.Datetime.to_string(delivery_date_utc) + price = delivery.get('ShipmentCharges', {}).get('TotalCharge', 0.0) + + carrier = self.gso_find_delivery_carrier_for_service(delivery['ServiceCode']) + if carrier: + rates.append({ + 'carrier': carrier, + 'package': package or self.env['stock.quant.package'].browse(), + 'success': True, + 'price': price, + 'error_message': False, + 'warning_message': _('TotalCharge not found.') if price == 0.0 else False, + 'date_planned': date_planned, + 'date_delivered': delivery_date_utc, + 'transit_days': False, + 'service_code': delivery['ServiceCode'], + }) + + return rates + + def gso_find_delivery_carrier_for_service(self, service_code): + if self.gso_service_type == service_code: + return self + # arbitrary decision, lets find the same account number + carrier = self.search([('gso_account_number', '=', self.gso_account_number), + ('gso_service_type', '=', service_code) + ], limit=1) + return carrier diff --git a/delivery_gso/models/requests_gso.py b/delivery_gso/models/requests_gso.py new file mode 100644 index 00000000..4e512661 --- /dev/null +++ b/delivery_gso/models/requests_gso.py @@ -0,0 +1,49 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +import requests +from json import dumps + + +class GSORequest: + + BASE_URL = 'https://api.gso.com/Rest/v1' + + def __init__(self, production, username, password, account_number): + self.username = username + self.password = password + self.account_number = account_number + self.headers = self.make_headers() + self._get_token() + + def make_headers(self): + return { + 'Content-Type': 'application/json', + 'Accept-Encoding': 'gzip', + 'UserName': self.username, + 'PassWord': self.password, + 'AccountNumber': self.account_number, + } + + # Token Lasts 12 hours and should be refreshed accordingly. + # Might need to change to prevent too many calls to the API + def _get_token(self): + endpoint_url = self.BASE_URL + '/token' + response = requests.get(endpoint_url, headers=self.headers) + response.raise_for_status() + self.headers.update({'Token': response.headers['Token']}) + + def call(self, http_method, endpoint_url, payload): + url = self.BASE_URL + endpoint_url + result = requests.request(http_method, url, data=dumps(payload), headers=self.headers) + if result.status_code != 200: + raise requests.exceptions.HTTPError(result.text) + return result.json() + + def post_shipment(self, request_body): + return self.call('POST', '/Shipment', request_body) + + def delete_shipment(self, request_body): + return self.call('DELETE', '/Shipment', request_body) + + def get_rates_and_transit_time(self, request_body): + return self.call('POST', '/RatesAndTransitTimes', request_body) diff --git a/delivery_gso/views/delivery_gso_view.xml b/delivery_gso/views/delivery_gso_view.xml new file mode 100644 index 00000000..ec57d2fe --- /dev/null +++ b/delivery_gso/views/delivery_gso_view.xml @@ -0,0 +1,28 @@ + + + + + delivery.carrier.form.provider.gso + delivery.carrier + + + + + + + + + + + + + + + + + + + + + +