diff --git a/delivery_fedex_hibou/__init__.py b/delivery_fedex_hibou/__init__.py index 0650744f..09434554 100644 --- a/delivery_fedex_hibou/__init__.py +++ b/delivery_fedex_hibou/__init__.py @@ -1 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from . import models diff --git a/delivery_fedex_hibou/__manifest__.py b/delivery_fedex_hibou/__manifest__.py index 4100408e..36714d33 100644 --- a/delivery_fedex_hibou/__manifest__.py +++ b/delivery_fedex_hibou/__manifest__.py @@ -1,9 +1,9 @@ { 'name': 'Hibou Fedex Shipping', - 'version': '11.0.1.0.0', + 'version': '11.0.1.1.0', 'category': 'Stock', 'author': "Hibou Corp.", - 'license': 'AGPL-3', + 'license': 'OPL-1', 'website': 'https://hibou.io/', 'depends': [ 'delivery_fedex', diff --git a/delivery_fedex_hibou/models/__init__.py b/delivery_fedex_hibou/models/__init__.py index cfdf1b44..bad254c1 100644 --- a/delivery_fedex_hibou/models/__init__.py +++ b/delivery_fedex_hibou/models/__init__.py @@ -1,2 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from . import delivery_fedex from . import stock diff --git a/delivery_fedex_hibou/models/delivery_fedex.py b/delivery_fedex_hibou/models/delivery_fedex.py index f5ee4ad0..b7c7c051 100644 --- a/delivery_fedex_hibou/models/delivery_fedex.py +++ b/delivery_fedex_hibou/models/delivery_fedex.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + import logging import time from odoo import fields, models, tools, _ @@ -17,6 +19,10 @@ class DeliveryFedex(models.Model): ('FEDEX_EXPRESS_SAVER', 'FEDEX_EXPRESS_SAVER'), ]) + def _fedex_convert_weight(self, weight, unit): + # dummy converter + return weight + def _get_fedex_is_third_party(self, order=None, picking=None): third_party_account = self.get_third_party_account(order=order, picking=picking) if third_party_account: @@ -112,6 +118,8 @@ class DeliveryFedex(models.Model): date_planned = None if self.env.context.get('date_planned'): date_planned = self.env.context.get('date_planned') + if date_planned and isinstance(date_planned, str): + date_planned = fields.Datetime.from_string(date_planned) # Authentication stuff @@ -205,6 +213,15 @@ class DeliveryFedex(models.Model): srm = FedexRequest(self.log_xml, request_type="shipping", prod_environment=self.prod_environment) superself = self.sudo() + 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 package_carriers and not picking_packages: + continue + shipper_company = superself.get_shipper_company(picking=picking) shipper_warehouse = superself.get_shipper_warehouse(picking=picking) recipient = superself.get_recipient(picking=picking) @@ -222,7 +239,7 @@ class DeliveryFedex(models.Model): # Not the actual reference. Using `shipment_name` during `add_package` calls. srm.transaction_detail(picking.id) - package_type = picking.package_ids and picking.package_ids[0].packaging_id.shipper_package_code or self.fedex_default_packaging_id.shipper_package_code + package_type = picking_packages and picking_packages[0].packaging_id.shipper_package_code or self.fedex_default_packaging_id.shipper_package_code srm.shipment_request(self.fedex_droppoff_type, self.fedex_service_type, package_type, self.fedex_weight_unit, self.fedex_saturday_delivery) srm.set_currency(_convert_curr_iso_fdx(picking.company_id.currency_id.name)) srm.set_shipper(shipper_company, shipper_warehouse) @@ -258,7 +275,7 @@ class DeliveryFedex(models.Model): srm.customs_value(_convert_curr_iso_fdx(commodity_currency.name), total_commodities_amount, "NON_DOCUMENTS") srm.duties_payment(picking.picking_type_id.warehouse_id.partner_id.country_id.code, superself.fedex_account_number) - package_count = len(picking.package_ids) or 1 + package_count = len(picking_packages) or 1 # TODO RIM master: factorize the following crap @@ -280,13 +297,23 @@ class DeliveryFedex(models.Model): package_labels = [] carrier_tracking_ref = "" - for sequence, package in enumerate(picking.package_ids, start=1): - - package_weight = _convert_weight(package.shipping_weight, self.fedex_weight_unit) + for sequence, package in enumerate(picking_packages, start=1): + package_weight = self._fedex_convert_weight(package.shipping_weight, self.fedex_weight_unit) + packaging = package.packaging_id + packaging_code = packaging.shipper_package_code if (packaging.package_carrier_type == 'fedex' and packaging.shipper_package_code) else self.fedex_default_packaging_id.shipper_package_code # Hibou Delivery # Add more details to package. - srm.add_package(package_weight, sequence_number=sequence, ref=('%s-%d' % (order_name, sequence)), insurance=insurance_value) + srm.add_package( + package_weight, + # package_code=packaging_code, + # package_height=packaging.height, + # package_width=packaging.width, + # package_length=packaging.length, + sequence_number=sequence, + ref=('%s-%d' % (order_name, sequence)), + insurance=insurance_value + ) srm.set_master_package(net_weight, package_count, master_tracking_id=master_tracking_id) request = srm.process_shipment() package_name = package.name or sequence @@ -350,9 +377,17 @@ class DeliveryFedex(models.Model): # One package # ############### elif package_count == 1: - # Hibou Delivery - # Add more details to package. - srm.add_package(net_weight, ref=order_name, insurance=insurance_value) + packaging = picking_packages[:1].packaging_id or self.fedex_default_packaging_id + packaging_code = packaging.shipper_package_code if packaging.package_carrier_type == 'fedex' else self.fedex_default_packaging_id.shipper_package_code + srm.add_package( + net_weight, + # package_code=packaging_code, + # package_height=packaging.height, + # package_width=packaging.width, + # package_length=packaging.length, + ref=order_name, + insurance=insurance_value + ) srm.set_master_package(net_weight, 1) # Ask the shipping to fedex @@ -394,3 +429,238 @@ class DeliveryFedex(models.Model): raise UserError(_('No packages for this picking')) return res + + def fedex_rate_shipment_multi(self, order=None, picking=None, packages=None): + if not packages: + return self._fedex_rate_shipment_multi_package(order=order, picking=picking) + else: + rates = [] + for package in packages: + rates += self._fedex_rate_shipment_multi_package(order=order, picking=picking, package=package) + return rates + + def _fedex_rate_shipment_multi_package(self, order=None, picking=None, package=None): + if order: + max_weight = self._fedex_convert_weight(self.fedex_default_packaging_id.max_weight, self.fedex_weight_unit) + is_india = order.partner_shipping_id.country_id.code == 'IN' and order.company_id.partner_id.country_id.code == 'IN' + est_weight_value = sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line]) or 0.0 + weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit) + order_currency = order.currency_id + elif not package: + is_india = picking.partner_id.country_id.code == 'IN' and picking.company_id.partner_id.country_id.code == 'IN' + est_weight_value = sum([(line.product_id.weight * (line.qty_done or line.product_uom_qty)) for line in picking.move_line_ids]) or 0.0 + weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit) + order_currency = picking.sale_id.currency_id if picking.sale_id else picking.company_id.currency_id + else: + is_india = picking.partner_id.country_id.code == 'IN' and picking.company_id.partner_id.country_id.code == 'IN' + order_currency = picking.sale_id.currency_id if picking.sale_id else picking.company_id.currency_id + est_weight_value = package.shipping_weight or package.weight + weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit) + + + price = 0.0 + + # Some users may want to ship very lightweight items; in order to give them a rating, we round the + # converted weight of the shipping to the smallest value accepted by FedEx: 0.01 kg or lb. + # (in the case where the weight is actually 0.0 because weights are not set, don't do this) + if weight_value > 0.0: + weight_value = max(weight_value, 0.01) + + superself = self.sudo() + + # Hibou Delivery methods for collecting details in an overridable way + shipper_company = superself.get_shipper_company(order=order, picking=picking) + shipper_warehouse = superself.get_shipper_warehouse(order=order, picking=picking) + recipient = superself.get_recipient(order=order, picking=picking) + acc_number = superself._get_fedex_account_number(order=order, picking=picking) + meter_number = superself._get_fedex_meter_number(order=order, picking=picking) + order_name = superself.get_order_name(order=order, picking=picking) + insurance_value = superself.get_insurance_value(order=order, picking=picking) + residential = self._get_fedex_recipient_is_residential(recipient) + date_planned = fields.Datetime.now() + if self.env.context.get('date_planned'): + date_planned = self.env.context.get('date_planned') + if date_planned and isinstance(date_planned, str): + date_planned = fields.Datetime.from_string(date_planned) + + # Authentication stuff + srm = FedexRequest(self.log_xml, request_type="rating", prod_environment=self.prod_environment) + srm.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password) + srm.client_detail(acc_number, meter_number) + + # Build basic rating request and set addresses + srm.transaction_detail(order_name) + + # TODO what shipment requests can we make? + # TODO package and main weights? + # TODO need package & weight count to pass in + srm.shipment_request( + self.fedex_droppoff_type, + None, # because we want all of the rates back... + self.fedex_default_packaging_id.shipper_package_code, + self.fedex_weight_unit, + self.fedex_saturday_delivery, + ship_timestamp=date_planned, + ) + pkg = self.fedex_default_packaging_id + + srm.set_currency(_convert_curr_iso_fdx(order_currency.name)) + srm.set_shipper(shipper_company, shipper_warehouse) + srm.set_recipient(recipient, residential=residential) + + if order and max_weight and weight_value > max_weight: + total_package = int(weight_value / max_weight) + last_package_weight = weight_value % max_weight + + for sequence in range(1, total_package + 1): + srm.add_package( + max_weight, + # package_code=pkg.shipper_package_code, + # package_height=pkg.height, + # package_width=pkg.width, + # package_length=pkg.length, + sequence_number=sequence, + mode='rating', + ) + if last_package_weight: + total_package = total_package + 1 + srm.add_package( + last_package_weight, + # package_code=pkg.shipper_package_code, + # package_height=pkg.height, + # package_width=pkg.width, + # package_length=pkg.length, + sequence_number=total_package, + mode='rating', + ) + srm.set_master_package(weight_value, total_package) + elif order: + srm.add_package( + weight_value, + # package_code=pkg.shipper_package_code, + # package_height=pkg.height, + # package_width=pkg.width, + # package_length=pkg.length, + mode='rating', + ) + srm.set_master_package(weight_value, 1) + else: + if package: + package_weight = self._fedex_convert_weight(package.shipping_weight or package.weight, self.fedex_weight_unit) + packaging = package.packaging_id + package_code = package.packaging_id.shipper_package_code if packaging.package_carrier_type == 'fedex' else self.fedex_default_packaging_id.shipper_package_code + + srm.add_package( + package_weight, + mode='rating', + # package_code=package_code, + # package_height=packaging.height, + # package_width=packaging.width, + # package_length=packaging.length, + sequence_number=1, + ref=('%s-%d' % (order_name, 1)), + insurance=insurance_value + ) + else: + # deliver all together... + package_weight = self._fedex_convert_weight(picking.shipping_weight or picking.weight, self.fedex_weight_unit) + packaging = self.fedex_default_packaging_id + + srm.add_package( + package_weight, + mode='rating', + # package_code=packaging.shipper_package_code, + # package_height=packaging.height, + # package_width=packaging.width, + # package_length=packaging.length, + sequence_number=1, + # po_number=po_number, + # dept_number=dept_number, + ref=('%s-%d' % (order_name, 1)), + insurance=insurance_value + ) + + + # Commodities for customs declaration (international shipping) + # TODO Intestigate rating if needed... + # if self.fedex_service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY'] or is_india: + # total_commodities_amount = 0.0 + # commodity_country_of_manufacture = order.warehouse_id.partner_id.country_id.code + # + # for line in order.order_line.filtered(lambda l: l.product_id.type in ['product', 'consu']): + # commodity_amount = line.price_total / line.product_uom_qty + # total_commodities_amount += (commodity_amount * line.product_uom_qty) + # commodity_description = line.product_id.name + # commodity_number_of_piece = '1' + # commodity_weight_units = self.fedex_weight_unit + # commodity_weight_value = self._fedex_convert_weight(line.product_id.weight * line.product_uom_qty, + # self.fedex_weight_unit) + # commodity_quantity = line.product_uom_qty + # commodity_quantity_units = 'EA' + # # DO NOT FORWARD PORT AFTER 12.0 + # if getattr(line.product_id, 'hs_code', False): + # commodity_harmonized_code = line.product_id.hs_code or '' + # else: + # commodity_harmonized_code = '' + # srm._commodities(_convert_curr_iso_fdx(order_currency.name), commodity_amount, + # commodity_number_of_piece, commodity_weight_units, commodity_weight_value, + # commodity_description, commodity_country_of_manufacture, commodity_quantity, + # commodity_quantity_units, commodity_harmonized_code) + # srm.customs_value(_convert_curr_iso_fdx(order_currency.name), total_commodities_amount, "NON_DOCUMENTS") + # srm.duties_payment(order.warehouse_id.partner_id.country_id.code, superself.fedex_account_number) + + request_list = srm.rate(date_planned=date_planned, multi=True) + result = [] + for request in request_list: + price = 0.0 + warnings = request.get('warnings_message') + if warnings: + _logger.info(warnings) + + if not request.get('errors_message'): + if _convert_curr_iso_fdx(order_currency.name) in request['price']: + price = request['price'][_convert_curr_iso_fdx(order_currency.name)] + else: + _logger.info("Preferred currency has not been found in FedEx response") + company_currency = order.company_id.currency_id + if _convert_curr_iso_fdx(company_currency.name) in request['price']: + amount = request['price'][_convert_curr_iso_fdx(company_currency.name)] + price = company_currency._convert(amount, order_currency, order.company_id, + order.date_order or fields.Date.today()) + else: + amount = request['price']['USD'] + price = company_currency._convert(amount, order_currency, order.company_id, + order.date_order or fields.Date.today()) + else: + result.append({'carrier': self, + 'success': False, + 'price': 0.0, + 'error_message': _('Error:\n%s') % request['errors_message'], + 'warning_message': False, + 'service_code': request['service_code'], + }) + service_code = request['service_code'] + carrier = self.fedex_find_delivery_carrier_for_service(service_code) + if carrier: + date_delivered = request.get('date_delivered', False) + result.append({'carrier': carrier, + 'package': package or self.env['stock.quant.package'].browse(), + 'success': True, + 'price': price, + 'error_message': False, + 'transit_days': request.get('transit_days', False), + 'date_delivered': date_delivered, + 'date_planned': date_planned, + 'warning_message': _('Warning:\n%s') % warnings if warnings else False, + 'service_code': request['service_code'], + }) + return result + + def fedex_find_delivery_carrier_for_service(self, service_code): + if self.fedex_service_type == service_code: + return self + # arbitrary decision, lets find the same account number + carrier = self.search([('fedex_account_number', '=', self.fedex_account_number), + ('fedex_service_type', '=', service_code) + ], limit=1) + return carrier diff --git a/delivery_fedex_hibou/models/fedex_request.py b/delivery_fedex_hibou/models/fedex_request.py index 56a61d82..109bd1de 100644 --- a/delivery_fedex_hibou/models/fedex_request.py +++ b/delivery_fedex_hibou/models/fedex_request.py @@ -1,9 +1,13 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + import suds from odoo.addons.delivery_fedex.models import fedex_request +from datetime import datetime +from copy import deepcopy from pprint import pformat import logging _logger = logging.getLogger(__name__) - +# logging.getLogger('suds.client').setLevel(logging.DEBUG) STATECODE_REQUIRED_COUNTRIES = fedex_request.STATECODE_REQUIRED_COUNTRIES @@ -130,20 +134,44 @@ class FedexRequest(fedex_request.FedexRequest): Payor.ResponsibleParty.AccountNumber = shipping_charges_payment_account self.RequestedShipment.ShippingChargesPayment.Payor = Payor + def shipment_request(self, dropoff_type, service_type, packaging_type, overall_weight_unit, saturday_delivery, ship_timestamp=None): + self.RequestedShipment = self.client.factory.create('RequestedShipment') + self.RequestedShipment.ShipTimestamp = ship_timestamp or datetime.now() + self.RequestedShipment.DropoffType = dropoff_type + self.RequestedShipment.ServiceType = service_type + self.RequestedShipment.PackagingType = packaging_type + # Resuest estimation of duties and taxes for international shipping + if service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY']: + self.RequestedShipment.EdtRequestType = 'ALL' + else: + self.RequestedShipment.EdtRequestType = 'NONE' + self.RequestedShipment.PackageCount = 0 + self.RequestedShipment.TotalWeight.Units = overall_weight_unit + self.RequestedShipment.TotalWeight.Value = 0 + self.listCommodities = [] + if saturday_delivery: + timestamp_day = self.RequestedShipment.ShipTimestamp.strftime("%A") + if (service_type == 'FEDEX_2_DAY' and timestamp_day == 'Thursday') or (service_type in ['PRIORITY_OVERNIGHT', 'FIRST_OVERNIGHT', 'INTERNATIONAL_PRIORITY'] and timestamp_day == 'Friday'): + SpecialServiceTypes = self.client.factory.create('ShipmentSpecialServiceType') + self.RequestedShipment.SpecialServicesRequested.SpecialServiceTypes = [SpecialServiceTypes.SATURDAY_DELIVERY] + # Rating stuff - def rate(self, date_planned=None): + def rate(self, date_planned=None, multi=False): """ Response will contain 'transit_days' key with number of days. :param date_planned: Planned Outgoing shipment. Used to have FedEx tell us how long it will take for the package to arrive. :return: """ + if multi: + multi_result = [] + if date_planned: + self.RequestedShipment.ShipTimestamp = date_planned + formatted_response = {'price': {}} del self.ClientDetail.Region - - if date_planned: - # though Fedex sends BACK timestamps like `2020-01-01 00:00:00` they EXPECT `2020-01-01T00:00:00` - self.RequestedShipment.ShipTimestamp = date_planned.replace(' ', 'T') + if self.hasCommodities: + self.RequestedShipment.CustomsClearanceDetail.Commodities = self.listCommodities try: self.response = self.client.service.getRates(WebAuthenticationDetail=self.WebAuthenticationDetail, @@ -152,23 +180,48 @@ class FedexRequest(fedex_request.FedexRequest): Version=self.VersionId, RequestedShipment=self.RequestedShipment, ReturnTransitAndCommit=True) # New ReturnTransitAndCommit for CommitDetails in response - if (self.response.HighestSeverity != 'ERROR' and self.response.HighestSeverity != 'FAILURE'): - for rating in self.response.RateReplyDetails[0].RatedShipmentDetails: - formatted_response['price'][rating.ShipmentRateDetail.TotalNetFedExCharge.Currency] = rating.ShipmentRateDetail.TotalNetFedExCharge.Amount - if len(self.response.RateReplyDetails[0].RatedShipmentDetails) == 1: - if 'CurrencyExchangeRate' in self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail: - formatted_response['price'][self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount / self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.Rate - # Hibou Delivery Planning - if hasattr(self.response.RateReplyDetails[0], 'DeliveryTimestamp') and self.response.RateReplyDetails[0].DeliveryTimestamp: - formatted_response['date_delivered'] = self.response.RateReplyDetails[0].DeliveryTimestamp - elif hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'CommitTimestamp'): - formatted_response['date_delivered'] = self.response.RateReplyDetails[0].CommitDetails[0].CommitTimestamp - formatted_response['transit_days'] = self._service_transit_days.get(self.response.RateReplyDetails[0].CommitDetails[0].ServiceType, 0) - elif hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'TransitTime'): - transit_days = self.response.RateReplyDetails[0].CommitDetails[0].TransitTime - transit_days = self._transit_days.get(transit_days, 0) - formatted_response['transit_days'] = transit_days + if (self.response.HighestSeverity != 'ERROR' and self.response.HighestSeverity != 'FAILURE'): + if not getattr(self.response, "RateReplyDetails", False): + raise Exception("No rating found") + + if not multi: + for rating in self.response.RateReplyDetails[0].RatedShipmentDetails: + formatted_response['price'][rating.ShipmentRateDetail.TotalNetFedExCharge.Currency] = rating.ShipmentRateDetail.TotalNetFedExCharge.Amount + if len(self.response.RateReplyDetails[0].RatedShipmentDetails) == 1: + if 'CurrencyExchangeRate' in self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail: + formatted_response['price'][self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount / self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.Rate + + # Hibou Delivery Planning + if hasattr(self.response.RateReplyDetails[0], 'DeliveryTimestamp') and self.response.RateReplyDetails[0].DeliveryTimestamp: + formatted_response['date_delivered'] = self.response.RateReplyDetails[0].DeliveryTimestamp + elif hasattr(self.response.RateReplyDetails[0], 'CommitDetails') and hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'CommitTimestamp'): + formatted_response['date_delivered'] = self.response.RateReplyDetails[0].CommitDetails[0].CommitTimestamp + formatted_response['transit_days'] = self._service_transit_days.get(self.response.RateReplyDetails[0].CommitDetails[0].ServiceType, 0) + elif hasattr(self.response.RateReplyDetails[0], 'CommitDetails') and hasattr(self.response.RateReplyDetails[0].CommitDetails[0], 'TransitTime'): + transit_days = self.response.RateReplyDetails[0].CommitDetails[0].TransitTime + transit_days = self._transit_days.get(transit_days, 0) + formatted_response['transit_days'] = transit_days + else: + for rate_reply_detail in self.response.RateReplyDetails: + res = deepcopy(formatted_response) + res['service_code'] = rate_reply_detail.ServiceType + for rating in rate_reply_detail.RatedShipmentDetails: + res['price'][rating.ShipmentRateDetail.TotalNetFedExCharge.Currency] = rating.ShipmentRateDetail.TotalNetFedExCharge.Amount + if len(rate_reply_detail.RatedShipmentDetails) == 1: + if 'CurrencyExchangeRate' in rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail: + res['price'][rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount / rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.Rate + # Hibou Delivery Planning + if hasattr(rate_reply_detail, 'DeliveryTimestamp') and rate_reply_detail.DeliveryTimestamp: + res['date_delivered'] = rate_reply_detail.DeliveryTimestamp + elif hasattr(rate_reply_detail, 'CommitDetails') and hasattr(rate_reply_detail.CommitDetails[0], 'CommitTimestamp'): + res['date_delivered'] = rate_reply_detail.CommitDetails[0].CommitTimestamp + res['transit_days'] = self._service_transit_days.get(rate_reply_detail.CommitDetails[0].ServiceType, 0) + elif hasattr(rate_reply_detail, 'CommitDetails') and hasattr(rate_reply_detail.CommitDetails[0], 'TransitTime'): + transit_days = rate_reply_detail.CommitDetails[0].TransitTime + transit_days = self._transit_days.get(transit_days, 0) + res['transit_days'] = transit_days + multi_result.append(res) else: errors_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if (n.Severity == 'ERROR' or n.Severity == 'FAILURE')]) @@ -182,5 +235,9 @@ class FedexRequest(fedex_request.FedexRequest): formatted_response['errors_message'] = fault except IOError: formatted_response['errors_message'] = "Fedex Server Not Found" + except Exception as e: + formatted_response['errors_message'] = e.args[0] + if multi: + return multi_result return formatted_response diff --git a/delivery_fedex_hibou/models/stock.py b/delivery_fedex_hibou/models/stock.py index 3dfb1a1f..58726f6f 100644 --- a/delivery_fedex_hibou/models/stock.py +++ b/delivery_fedex_hibou/models/stock.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + from odoo import api, fields, models diff --git a/delivery_gso/__init__.py b/delivery_gso/__init__.py index 0650744f..09434554 100644 --- a/delivery_gso/__init__.py +++ b/delivery_gso/__init__.py @@ -1 +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 index 3f94b3c9..545fa68b 100644 --- a/delivery_gso/__manifest__.py +++ b/delivery_gso/__manifest__.py @@ -1,10 +1,10 @@ { 'name': 'Golden State Overnight (gso.com) Shipping', 'summary': 'Send your shippings through gso.com and track them online.', - 'version': '11.0.1.0.0', + 'version': '11.0.1.1.0', 'author': "Hibou Corp.", 'category': 'Warehouse', - 'license': 'AGPL-3', + 'license': 'OPL-1', 'images': [], 'website': "https://hibou.io", 'description': """ diff --git a/delivery_gso/models/__init__.py b/delivery_gso/models/__init__.py index 943392d3..c9a65a9e 100644 --- a/delivery_gso/models/__init__.py +++ b/delivery_gso/models/__init__.py @@ -1 +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 index 84bb6477..0c315bf8 100644 --- a/delivery_gso/models/delivery_gso.py +++ b/delivery_gso/models/delivery_gso.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + import pytz from math import ceil from requests import HTTPError @@ -160,7 +162,7 @@ class ProviderGSO(models.Model): 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' + address_type = 'B' if bool(to.is_company or to.parent_id.is_company) else 'R' request_body = { 'AccountNumber': sudoself.gso_account_number, @@ -188,9 +190,15 @@ class ProviderGSO(models.Model): 'thermal': [], 'paper': [], } - if picking.package_ids: + 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.package_ids: + 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 @@ -207,7 +215,8 @@ class ProviderGSO(models.Model): cost += response['ShipmentCharges']['TotalCharge'] except HTTPError as e: raise ValidationError(e) - else: + 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 @@ -224,6 +233,8 @@ class ProviderGSO(models.Model): 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']] @@ -251,7 +262,7 @@ class ProviderGSO(models.Model): } for tracking in picking.carrier_tracking_ref.split(','): request_body['TrackingNumber'] = tracking - _ = service.delete_shipment(request_body) + cancel_res = 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, ))) @@ -262,7 +273,7 @@ class ProviderGSO(models.Model): 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' + address_type = 'B' if bool(to.is_company or to.parent_id.is_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) @@ -319,3 +330,106 @@ class ProviderGSO(models.Model): 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 = self._gso_convert_weight(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 index e5fceb3a..4e512661 100644 --- a/delivery_gso/models/requests_gso.py +++ b/delivery_gso/models/requests_gso.py @@ -1,3 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + import requests from json import dumps diff --git a/delivery_hibou/__manifest__.py b/delivery_hibou/__manifest__.py index e53e01b9..d4380058 100644 --- a/delivery_hibou/__manifest__.py +++ b/delivery_hibou/__manifest__.py @@ -1,7 +1,7 @@ { 'name': 'Delivery Hibou', 'summary': 'Adds underlying pinnings for things like "RMA Return Labels"', - 'version': '11.0.1.0.0', + 'version': '11.0.1.1.0', 'author': "Hibou Corp.", 'category': 'Stock', 'license': 'AGPL-3', diff --git a/delivery_hibou/models/delivery.py b/delivery_hibou/models/delivery.py index 50088e78..c7bff160 100644 --- a/delivery_hibou/models/delivery.py +++ b/delivery_hibou/models/delivery.py @@ -1,5 +1,6 @@ -from odoo import fields, models +from odoo import api, fields, models from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES +from odoo.exceptions import UserError class DeliveryCarrier(models.Model): @@ -157,3 +158,89 @@ class DeliveryCarrier(models.Model): def _get_recipient_out(self, picking): return picking.partner_id + + # -------------------------- # + # API for external providers # + # -------------------------- # + @api.multi + def rate_shipment_multi(self, order=None, picking=None, packages=None): + ''' Compute the price of the order shipment + + :param order: record of sale.order or None + :param picking: record of stock.picking or None + :param packages: recordset of stock.quant.package or None (requires picking also set) + :return list: dict: { + 'carrier': delivery.carrier(), + 'success': boolean, + 'price': a float, + 'error_message': a string containing an error message, + 'warning_message': a string containing a warning message, + 'date_planned': a datetime for when the shipment is supposed to leave, + 'date_delivered': a datetime for when the shipment is supposed to arrive, + 'transit_days': a Float for how many days it takes in transit, + 'service_code': a string that represents the service level/agreement, + 'package': stock.quant.package(), + } + + e.g. self == delivery.carrier(5, 6) + then return might be: + [ + {'carrier': delivery.carrier(5), 'price': 10.50, 'service_code': 'GROUND_HOME_DELIVERY', ...}, + {'carrier': delivery.carrier(7), 'price': 12.99, 'service_code': 'FEDEX_EXPRESS_SAVER', ...}, # NEW! + {'carrier': delivery.carrier(6), 'price': 8.0, 'service_code': 'USPS_PRI', ...}, + ] + ''' + self.ensure_one() + + if picking: + self = self.with_context(date_planned=fields.Datetime.now()) + if not packages: + packages = picking.package_ids + else: + if packages: + raise UserError('Cannot rate package without picking.') + self = self.with_context(date_planned=(order.date_planned or fields.Datetime.now())) + + res = [] + for carrier in self: + carrier_packages = packages.filtered(lambda p: not p.carrier_tracking_ref and + (not p.carrier_id or p.carrier_id == carrier) and + p.packaging_id.package_carrier_type in (False, '', 'none', carrier.delivery_type)) + if packages and not carrier_packages: + continue + if hasattr(carrier, '%s_rate_shipment_multi' % self.delivery_type): + try: + res += getattr(carrier, '%s_rate_shipment_multi' % carrier.delivery_type)(order=order, + picking=picking, + packages=carrier_packages) + except TypeError: + # TODO remove catch if after Odoo 14 + # This is intended to find ones that don't support packages= kwarg + res += getattr(carrier, '%s_rate_shipment_multi' % carrier.delivery_type)(order=order, + picking=picking) + + return res + + def cancel_shipment(self, pickings, packages=None): + ''' Cancel a shipment + + :param pickings: A recordset of pickings + :param packages: Optional recordset of packages (should be for this carrier) + ''' + self.ensure_one() + if hasattr(self, '%s_cancel_shipment' % self.delivery_type): + # No good way to tell if this method takes the kwarg for packages + if packages: + try: + return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings, packages=packages) + except TypeError: + # we won't be able to cancel the packages properly + # here we will TRY to make a good call here where we put the package references into the picking + # and let the original mechanisms try to work here + tracking_ref = ','.join(packages.mapped('carrier_tracking_ref')) + pickings.write({ + 'carrier_id': self.id, + 'carrier_tracking_ref': tracking_ref, + }) + + return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings) diff --git a/delivery_hibou/models/stock.py b/delivery_hibou/models/stock.py index c1a6a792..dfc4293e 100644 --- a/delivery_hibou/models/stock.py +++ b/delivery_hibou/models/stock.py @@ -1,4 +1,27 @@ -from odoo import api, fields, models +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + + +class StockQuantPackage(models.Model): + _inherit = 'stock.quant.package' + + carrier_id = fields.Many2one('delivery.carrier', string='Carrier') + carrier_tracking_ref = fields.Char(string='Tracking Reference') + + def _get_active_picking(self): + picking_id = self._context.get('active_id') + picking_model = self._context.get('active_model') + if not picking_id or picking_model != 'stock.picking': + raise UserError('Cannot cancel package other than through shipment/picking.') + return self.env['stock.picking'].browse(picking_id) + + def send_to_shipper(self): + picking = self._get_active_picking() + picking.with_context(packages=self).send_to_shipper() + + def cancel_shipment(self): + picking = self._get_active_picking() + picking.with_context(packages=self).cancel_shipment() class StockPicking(models.Model): @@ -11,6 +34,22 @@ class StockPicking(models.Model): ('no', 'No'), ], string='Require Insurance', default='auto', help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.') + package_carrier_tracking_ref = fields.Char(string='Package Tracking Numbers', compute='_compute_package_carrier_tracking_ref') + + @api.depends('package_ids.carrier_tracking_ref') + def _compute_package_carrier_tracking_ref(self): + for picking in self: + package_refs = picking.package_ids.filtered('carrier_tracking_ref').mapped('carrier_tracking_ref') + if package_refs: + picking.package_carrier_tracking_ref = ','.join(package_refs) + else: + picking.package_carrier_tracking_ref = False + + @api.onchange('carrier_id') + def _onchange_carrier_id_for_priority(self): + for picking in self: + if picking.carrier_id and picking.carrier_id.procurement_priority: + picking.priority = picking.carrier_id.procurement_priority @api.one @api.depends('move_lines.priority', 'carrier_id') @@ -27,6 +66,11 @@ class StockPicking(models.Model): so = self.env['sale.order'].search([('name', '=', str(origin))], limit=1) if so and so.shipping_account_id: values['shipping_account_id'] = so.shipping_account_id.id + carrier_id = values.get('carrier_id') + if carrier_id: + carrier = self.env['delivery.carrier'].browse(carrier_id) + if carrier.procurement_priority: + values['priority'] = carrier.procurement_priority res = super(StockPicking, self).create(values) return res @@ -39,6 +83,83 @@ class StockPicking(models.Model): cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0]) return cost + def clear_carrier_tracking_ref(self): + self.write({'carrier_tracking_ref': False}) + + def reset_carrier_tracking_ref(self): + for picking in self: + picking.carrier_tracking_ref = picking.package_carrier_tracking_ref + + # Override to send to specific packaging carriers + def send_to_shipper(self): + packages = self._context.get('packages') + self.ensure_one() + if not packages: + packages = self.package_ids + package_carriers = packages.mapped('carrier_id') + if not package_carriers: + # Original behavior + return super().send_to_shipper() + + tracking_numbers = [] + carrier_prices = [] + order_currency = self.sale_id.currency_id or self.company_id.currency_id + for carrier in package_carriers: + self.carrier_id = carrier + carrier_packages = packages.filtered(lambda p: p.carrier_id == carrier) + res = carrier.send_shipping(self) + if res: + res = res[0] + if carrier.free_over and self.sale_id and self.sale_id._compute_amount_total_without_delivery() >= carrier.amount: + res['exact_price'] = 0.0 + carrier_price = float(res['exact_price']) * (1.0 + (self.carrier_id.margin / 100.0)) + carrier_prices.append(carrier_price) + tracking_number = '' + if res['tracking_number']: + tracking_number = res['tracking_number'] + tracking_numbers.append(tracking_number) + # Try to add tracking to the individual packages. + potential_tracking_numbers = tracking_number.split(',') + if len(potential_tracking_numbers) >= len(carrier_packages): + for t, p in zip(potential_tracking_numbers, carrier_packages): + p.carrier_tracking_ref = t + else: + carrier_packages.write({'carrier_tracking_ref': tracking_number}) + msg = _("Shipment sent to carrier %s for shipping with tracking number %s
Cost: %.2f %s") % (carrier.name, tracking_number, carrier_price, order_currency.name) + self.message_post(body=msg) + + self.carrier_price = sum(carrier_prices or [0.0]) + self.carrier_tracking_ref = ','.join(tracking_numbers or ['']) + + # Override to provide per-package versions... + def cancel_shipment(self): + packages = self._context.get('packages') + pickings_with_package_tracking = self.filtered(lambda p: p.package_carrier_tracking_ref) + for picking in pickings_with_package_tracking: + if packages: + current_packages = packages + else: + current_packages = picking.package_ids + # Packages without a carrier can just be cleared + packages_without_carrier = current_packages.filtered(lambda p: not p.carrier_id and p.carrier_tracking_ref) + packages_without_carrier.write({ + 'carrier_tracking_ref': False, + }) + # Packages with carrier can use the carrier method + packages_with_carrier = current_packages.filtered(lambda p: p.carrier_id and p.carrier_tracking_ref) + carriers = packages_with_carrier.mapped('carrier_id') + for carrier in carriers: + carrier_packages = packages_with_carrier.filtered(lambda p: p.carrier_id == carrier) + carrier.cancel_shipment(self, packages=carrier_packages) + package_refs = ','.join(carrier_packages.mapped('carrier_tracking_ref')) + msg = "Shipment %s cancelled" % package_refs + picking.message_post(body=msg) + carrier_packages.write({'carrier_tracking_ref': False}) + + pickings_without_package_tracking = self - pickings_with_package_tracking + if pickings_without_package_tracking: + # use original on these + super(StockPicking, pickings_without_package_tracking).cancel_shipment() class StockMove(models.Model): diff --git a/delivery_hibou/tests/test_delivery_hibou.py b/delivery_hibou/tests/test_delivery_hibou.py index 68866f45..51a738ca 100644 --- a/delivery_hibou/tests/test_delivery_hibou.py +++ b/delivery_hibou/tests/test_delivery_hibou.py @@ -24,11 +24,11 @@ class TestDeliveryHibou(common.TransactionCase): def test_delivery_hibou(self): # Assign a new shipping account - self.partner.shipping_account_id = self.shipping_account + self.partner.shipping_account_ids = self.shipping_account # Assign values to new Carrier test_insurance_value = 600 - test_procurement_priority = '2' + test_procurement_priority = '1' self.carrier.automatic_insurance_value = test_insurance_value self.carrier.procurement_priority = test_procurement_priority @@ -128,9 +128,9 @@ class TestDeliveryHibou(common.TransactionCase): picking_in.carrier_id = self.carrier # This relies heavily on the 'stock' demo data. # Should only have a single move_line_ids and it should not be done at all. - self.assertEqual(picking_in.move_line_ids.mapped('qty_done'), [0.0]) - self.assertEqual(picking_in.move_line_ids.mapped('product_uom_qty'), [35.0]) - self.assertEqual(picking_in.move_line_ids.mapped('product_id.standard_price'), [55.0]) + self.assertEqual(picking_in.move_line_ids.mapped('qty_done'), [0.0, 0.0, 0.0]) + self.assertEqual(picking_in.move_line_ids.mapped('product_uom_qty'), [35.0, 10.0, 12.0]) + self.assertEqual(picking_in.move_line_ids.mapped('product_id.standard_price'), [55.0, 35.0, 1700.0]) self.assertEqual(picking_in.carrier_id._classify_picking(picking=picking_in), 'in') self.assertEqual(picking_in.carrier_id.get_shipper_company(picking=picking_in), diff --git a/delivery_hibou/views/delivery_views.xml b/delivery_hibou/views/delivery_views.xml index 01208bbd..638ef921 100644 --- a/delivery_hibou/views/delivery_views.xml +++ b/delivery_hibou/views/delivery_views.xml @@ -11,4 +11,16 @@ + + + hibou.choose.delivery.package.form + choose.delivery.package + + + + [('product_id', '=', False)] + + + + \ No newline at end of file diff --git a/delivery_hibou/views/stock_views.xml b/delivery_hibou/views/stock_views.xml index 78067b01..f806127d 100644 --- a/delivery_hibou/views/stock_views.xml +++ b/delivery_hibou/views/stock_views.xml @@ -1,14 +1,48 @@ + + hibou.stock.quant.package.form + stock.quant.package + + + + + + + hibou.delivery.stock.picking_withcarrier.form.view stock.picking + - + + +