diff --git a/delivery_gso/__init__.py b/delivery_gso/__init__.py
new file mode 100644
index 00000000..0650744f
--- /dev/null
+++ b/delivery_gso/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/delivery_gso/__manifest__.py b/delivery_gso/__manifest__.py
new file mode 100644
index 00000000..3f94b3c9
--- /dev/null
+++ b/delivery_gso/__manifest__.py
@@ -0,0 +1,25 @@
+{
+ 'name': 'Golden State Overnight (gso.com) Shipping',
+ 'summary': 'Send your shippings through gso.com and track them online.',
+ 'version': '11.0.1.0.0',
+ 'author': "Hibou Corp.",
+ 'category': 'Warehouse',
+ 'license': 'AGPL-3',
+ '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..943392d3
--- /dev/null
+++ b/delivery_gso/models/__init__.py
@@ -0,0 +1 @@
+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..84bb6477
--- /dev/null
+++ b/delivery_gso/models/delivery_gso.py
@@ -0,0 +1,321 @@
+import pytz
+from math import ceil
+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
+
+GSO_TZ = 'PST8PDT'
+
+
+class ProductPackaging(models.Model):
+ _inherit = 'product.packaging'
+
+ package_carrier_type = fields.Selection(selection_add=[('gso', 'gso.com')])
+
+
+class ProviderGSO(models.Model):
+ _inherit = 'delivery.carrier'
+
+ delivery_type = fields.Selection(selection_add=[('gso', 'gso.com')])
+ 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('product.packaging', 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
+ return {'Length': package_type.length, 'Width': package_type.width, 'Height': package_type.height}
+
+ def _gso_convert_weight(self, weight_in_kg):
+ # m(lb) = m(kg) / 0.45359237
+ weight_in_lb = weight_in_kg / 0.45359237
+ # 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 bool(to.company or to.parent_id.company) 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': [],
+ }
+ if picking.package_ids:
+ # Every package will be a transaction
+ for package in picking.package_ids:
+ 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)
+ else:
+ 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)
+
+ # 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']:
+ attachments += [('LabelGSO-%s.pdf' % (l[0], ), l[1]) for l in labels['thermal']]
+ 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 bool(to.company or to.parent_id.company) 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
diff --git a/delivery_gso/models/requests_gso.py b/delivery_gso/models/requests_gso.py
new file mode 100644
index 00000000..e5fceb3a
--- /dev/null
+++ b/delivery_gso/models/requests_gso.py
@@ -0,0 +1,47 @@
+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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+