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 18c01dbf..b5f4b357 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: @@ -36,6 +42,8 @@ class DeliveryFedex(models.Model): if not third_party_account.delivery_type == 'fedex': raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.') return third_party_account.name + if picking and picking.picking_type_id.warehouse_id.fedex_account_number: + return picking.picking_type_id.warehouse_id.fedex_account_number return self.fedex_account_number def _get_fedex_account_number(self, order=None, picking=None): @@ -112,6 +120,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 +215,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) @@ -213,7 +232,6 @@ class DeliveryFedex(models.Model): payment_acc_number = superself._get_fedex_payment_account_number(picking=picking) order_name = superself.get_order_name(picking=picking) attn = superself.get_attn(picking=picking) - insurance_value = superself.get_insurance_value(picking=picking) residential = self._get_fedex_recipient_is_residential(recipient) srm.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password) @@ -222,7 +240,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 +276,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 +298,24 @@ 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=superself.get_insurance_value(picking=picking, package=package), + signature_required=superself.get_signature_required(picking=picking, package=package) + ) 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 +379,18 @@ 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=superself.get_insurance_value(picking=picking, package=picking_packages[:1]), + signature_required=superself.get_signature_required(picking=picking, package=picking_packages[:1]) + ) srm.set_master_package(net_weight, 1) # Ask the shipping to fedex @@ -394,3 +432,265 @@ 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, package=package) + signature_required = superself.get_signature_required(order=order, picking=picking, package=package) + 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, + signature_required=signature_required + ) + 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, + signature_required=signature_required + ) + + + # 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 + + def fedex_cancel_shipment(self, picking): + request = FedexRequest(self.log_xml, request_type="shipping", prod_environment=self.prod_environment) + superself = self.sudo() + request.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password) + acc_number = superself._get_fedex_account_number(picking=picking) + meter_number = superself._get_fedex_meter_number(picking=picking) + request.client_detail(acc_number, meter_number) + request.transaction_detail(picking.id) + + master_tracking_id = picking.carrier_tracking_ref.split(',')[0] + request.set_deletion_details(master_tracking_id) + result = request.delete_shipment() + + warnings = result.get('warnings_message') + if warnings: + _logger.info(warnings) + + if result.get('delete_success') and not result.get('errors_message'): + picking.message_post(body=_(u'Shipment N° %s has been cancelled' % master_tracking_id)) + picking.write({'carrier_tracking_ref': '', + 'carrier_price': 0.0}) + else: + raise UserError(result['errors_message']) diff --git a/delivery_fedex_hibou/models/fedex_request.py b/delivery_fedex_hibou/models/fedex_request.py index 56a61d82..5e2e4418 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 @@ -31,6 +35,7 @@ class FedexRequest(fedex_request.FedexRequest): _service_transit_days = { 'FEDEX_2_DAY': 2, 'FEDEX_2_DAY_AM': 2, + 'FEDEX_3_DAY_FREIGHT': 3, 'FIRST_OVERNIGHT': 1, 'PRIORITY_OVERNIGHT': 1, 'STANDARD_OVERNIGHT': 1, @@ -76,7 +81,7 @@ class FedexRequest(fedex_request.FedexRequest): self.RequestedShipment.Recipient.Contact = Contact self.RequestedShipment.Recipient.Address = Address - def add_package(self, weight_value, sequence_number=False, mode='shipping', ref=False, insurance=False): + def add_package(self, weight_value, sequence_number=False, mode='shipping', ref=False, insurance=False, signature_required=False): """ Adds ref type of object to include. :param weight_value: default @@ -84,6 +89,7 @@ class FedexRequest(fedex_request.FedexRequest): :param mode: default :param ref: NEW add CUSTOMER_REFERENCE object :param insurance: NEW add Insurance amount + :param signature_required: NEW add signature required :return: """ package = self.client.factory.create('RequestedPackageLineItem') @@ -104,6 +110,12 @@ class FedexRequest(fedex_request.FedexRequest): insured.Currency = 'USD' package.InsuredValue = insured + special_service = self.client.factory.create("PackageSpecialServicesRequested") + signature_detail = self.client.factory.create("SignatureOptionDetail") + signature_detail.OptionType = 'DIRECT' if signature_required else 'NO_SIGNATURE_REQUIRED' + special_service.SignatureOptionDetail = signature_detail + package.SpecialServicesRequested = special_service + package.PhysicalPackaging = 'BOX' package.Weight = package_weight if mode == 'rating': @@ -130,20 +142,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 +188,57 @@ 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 + if 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 + 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 + res['transit_days'] = self._service_transit_days.get(rate_reply_detail.ServiceType, 0) + if not res['transit_days'] 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 + 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.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 +252,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..5a7309e4 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, @@ -175,22 +177,34 @@ class ProviderGSO(models.Model): 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: + 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: + # Use Sale Order Number or fall back to Picking + shipment_ref = (picking.sale_id.name if picking.sale_id else picking.name) + '-' + package.name + insurance_value = sudoself.get_insurance_value(picking=picking, package=package) + if insurance_value > 100.0: + # Documentation says to set DeclaredValue ONLY if over $100.00 + request_body['Shipment']['DeclaredValue'] = insurance_value + elif 'DeclaredValue' in request_body['Shipment']: + del request_body['Shipment']['DeclaredValue'] + + if sudoself.get_signature_required(picking=picking, package=package): + request_body['Shipment']['SignatureCode'] = 'SIG_REQD' + else: + request_body['Shipment']['SignatureCode'] = 'SIG_NOT_REQD' + 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,10 +221,12 @@ class ProviderGSO(models.Model): cost += response['ShipmentCharges']['TotalCharge'] except HTTPError as e: raise ValidationError(e) - else: + elif not package_carriers: + # ship the whole picking + shipment_ref = picking.sale_id.name if picking.sale_id else picking.name 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']['ShipmentReference'] = shipment_ref request_body['Shipment']['TrackingNumber'] = self._gso_create_tracking_number(picking.name) try: response = service.post_shipment(request_body) @@ -224,6 +240,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 +269,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 +280,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 +337,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..a776fb28 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.2.1', 'author': "Hibou Corp.", 'category': 'Stock', 'license': 'AGPL-3', diff --git a/delivery_hibou/models/delivery.py b/delivery_hibou/models/delivery.py index 50088e78..755593ce 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): @@ -8,6 +9,9 @@ class DeliveryCarrier(models.Model): automatic_insurance_value = fields.Float(string='Automatic Insurance Value', help='Will be used during shipping to determine if the ' 'picking\'s value warrants insurance being added.') + automatic_sig_req_value = fields.Float(string='Automatic Signature Required Value', + help='Will be used during shipping to determine if the ' + 'picking\'s value warrants signature required being added.') procurement_priority = fields.Selection(PROCUREMENT_PRIORITIES, string='Procurement Priority', help='Priority for this carrier. Will affect pickings ' @@ -15,21 +19,42 @@ class DeliveryCarrier(models.Model): # Utility - def get_insurance_value(self, order=None, picking=None): + def get_insurance_value(self, order=None, picking=None, package=None): value = 0.0 if order: if order.order_line: - value = sum(order.order_line.filtered(lambda l: l.type != 'service').mapped('price_subtotal')) + value = sum(order.order_line.filtered(lambda l: l.product_id.type != 'service').mapped('price_subtotal')) else: return value if picking: - value = picking.declared_value() - if picking.require_insurance == 'no': - value = 0.0 - elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value: + value = picking.declared_value(package=package) + if package and not package.require_insurance: value = 0.0 + else: + if picking.require_insurance == 'no': + value = 0.0 + elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value: + value = 0.0 return value + def get_signature_required(self, order=None, picking=None, package=None): + value = 0.0 + if order: + if order.order_line: + value = sum(order.order_line.filtered(lambda l: l.product_id.type != 'service').mapped('price_subtotal')) + else: + return False + if picking: + value = picking.declared_value(package=package) + if package: + return package.require_signature + else: + if picking.require_signature == 'no': + return False + elif picking.require_signature == 'yes': + return True + return self.automatic_sig_req_value and value >= self.automatic_sig_req_value + def get_third_party_account(self, order=None, picking=None): if order and order.shipping_account_id: return order.shipping_account_id @@ -157,3 +182,133 @@ 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 and 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) + + +class ChooseDeliveryPackage(models.TransientModel): + _inherit = 'choose.delivery.package' + + package_declared_value = fields.Float(string='Declared Value', + default=lambda self: self._default_package_declared_value()) + package_require_insurance = fields.Boolean(string='Require Insurance') + package_require_signature = fields.Boolean(string='Require Signature') + + def _default_package_declared_value(self): + # guard for install + if not self.env.context.get('active_id'): + return 0.0 + if self.env.context.get('default_stock_quant_package_id'): + stock_quant_package = self.env['stock.quant.package'].browse(self.env.context['default_stock_quant_package_id']) + return stock_quant_package.declared_value + else: + picking_id = self.env['stock.picking'].browse(self.env.context['active_id']) + move_line_ids = [po for po in picking_id.move_line_ids if po.qty_done > 0 and not po.result_package_id] + total_value = sum([po.qty_done * po.product_id.standard_price for po in move_line_ids]) + return total_value + + @api.onchange('package_declared_value') + def _onchange_package_declared_value(self): + picking = self.env['stock.picking'].browse(self.env.context.get('active_id', 0)) + value = self.package_declared_value + if picking.require_insurance == 'auto': + self.package_require_insurance = value and picking.carrier_id.automatic_insurance_value and value >= picking.carrier_id.automatic_insurance_value + else: + self.package_require_insurance = picking.require_insurance == 'yes' + if picking.require_signature == 'auto': + self.package_require_signature = value and picking.carrier_id.automatic_sig_req_value and value >= picking.carrier_id.automatic_sig_req_value + else: + self.package_require_signature = picking.require_signature == 'yes' + + def put_in_pack(self): + super().put_in_pack() + if self.stock_quant_package_id: + self.stock_quant_package_id.write({ + 'declared_value': self.package_declared_value, + 'require_insurance': self.package_require_insurance, + 'require_signature': self.package_require_signature, + }) diff --git a/delivery_hibou/models/stock.py b/delivery_hibou/models/stock.py index c1a6a792..7aa55838 100644 --- a/delivery_hibou/models/stock.py +++ b/delivery_hibou/models/stock.py @@ -1,4 +1,33 @@ -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') + require_insurance = fields.Boolean(string='Require Insurance') + require_signature = fields.Boolean(string='Require Signature') + declared_value = fields.Float(string='Declared Value') + + 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': + picking_id = self._context.get('picking_active_id') + picking_model = self._context.get('picking_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 +40,28 @@ 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.') + require_signature = fields.Selection([ + ('auto', 'Automatic'), + ('yes', 'Yes'), + ('no', 'No'), + ], string='Require Signature', default='auto', + help='If your carrier supports it, auto should be calculated off of the "Automatic Signature Required 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,18 +78,104 @@ 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 - def declared_value(self): + def declared_value(self, package=None): self.ensure_one() + if package: + return package.declared_value cost = sum([(l.product_id.standard_price * l.qty_done) for l in self.move_line_ids] or [0.0]) if not cost: # Assume Full Value 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) == 1: + potential_tracking_numbers = tracking_number.split('+') # UPS for example... + 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..abfd770e 100644 --- a/delivery_hibou/tests/test_delivery_hibou.py +++ b/delivery_hibou/tests/test_delivery_hibou.py @@ -24,12 +24,14 @@ 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_sig_req_value = 300 + test_procurement_priority = '1' self.carrier.automatic_insurance_value = test_insurance_value + self.carrier.automatic_sig_req_value = test_sig_req_value self.carrier.procurement_priority = test_procurement_priority @@ -71,7 +73,9 @@ class TestDeliveryHibou(common.TransactionCase): def test_carrier_hibou_out(self): test_insurance_value = 4000 + test_sig_req_value = 4000 self.carrier.automatic_insurance_value = test_insurance_value + self.carrier.automatic_sig_req_value = test_sig_req_value picking_out = self.env.ref('stock.outgoing_shipment_main_warehouse') self.assertEqual(picking_out.state, 'assigned') @@ -87,21 +91,29 @@ class TestDeliveryHibou(common.TransactionCase): # The 'value' is assumed to be all of the product value from the initial demand. self.assertEqual(picking_out.declared_value(), 15.0 * 3300.0) self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), picking_out.declared_value()) + self.assertTrue(picking_out.carrier_id.get_signature_required(picking=picking_out)) # Workflow where user explicitly opts out of insurance on the picking level. picking_out.require_insurance = 'no' + picking_out.require_signature = 'no' self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0) + self.assertFalse(picking_out.carrier_id.get_signature_required(picking=picking_out)) picking_out.require_insurance = 'auto' + picking_out.require_signature = 'auto' # Lets choose to only delivery one piece at the moment. # This does not meet the minimum on the carrier to have insurance value. picking_out.move_line_ids.qty_done = 1.0 self.assertEqual(picking_out.declared_value(), 3300.0) self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0) + self.assertFalse(picking_out.carrier_id.get_signature_required(picking=picking_out)) # Workflow where user opts in to insurance. picking_out.require_insurance = 'yes' + picking_out.require_signature = 'yes' self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 3300.0) + self.assertTrue(picking_out.carrier_id.get_signature_required(picking=picking_out)) picking_out.require_insurance = 'auto' + picking_out.require_signature = 'auto' # Test with picking having 3rd party account. self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), None) diff --git a/delivery_hibou/views/delivery_views.xml b/delivery_hibou/views/delivery_views.xml index 01208bbd..a1e4015f 100644 --- a/delivery_hibou/views/delivery_views.xml +++ b/delivery_hibou/views/delivery_views.xml @@ -7,8 +7,26 @@ + + + + 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..8cbf343c 100644 --- a/delivery_hibou/views/stock_views.xml +++ b/delivery_hibou/views/stock_views.xml @@ -1,14 +1,52 @@ + + hibou.stock.quant.package.form + stock.quant.package + + + + + + + hibou.delivery.stock.picking_withcarrier.form.view stock.picking + - + + + +