diff --git a/delivery_fedex_hibou/__init__.py b/delivery_fedex_hibou/__init__.py
new file mode 100644
index 00000000..09434554
--- /dev/null
+++ b/delivery_fedex_hibou/__init__.py
@@ -0,0 +1,3 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from . import models
diff --git a/delivery_fedex_hibou/__manifest__.py b/delivery_fedex_hibou/__manifest__.py
new file mode 100644
index 00000000..67f04fdf
--- /dev/null
+++ b/delivery_fedex_hibou/__manifest__.py
@@ -0,0 +1,19 @@
+{
+ 'name': 'Hibou Fedex Shipping',
+ 'version': '15.0.1.0.0',
+ 'category': 'Stock',
+ 'author': "Hibou Corp.",
+ 'license': 'OPL-1',
+ 'website': 'https://hibou.io/',
+ 'depends': [
+ 'delivery_fedex',
+ 'delivery_hibou',
+ ],
+ 'data': [
+ 'views/stock_views.xml',
+ ],
+ 'demo': [
+ ],
+ 'installable': True,
+ 'application': False,
+ }
diff --git a/delivery_fedex_hibou/models/__init__.py b/delivery_fedex_hibou/models/__init__.py
new file mode 100644
index 00000000..bad254c1
--- /dev/null
+++ b/delivery_fedex_hibou/models/__init__.py
@@ -0,0 +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
new file mode 100644
index 00000000..62fd44ea
--- /dev/null
+++ b/delivery_fedex_hibou/models/delivery_fedex.py
@@ -0,0 +1,742 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+import logging
+import pytz
+from odoo import fields, models, tools, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.addons.delivery_fedex.models.delivery_fedex import _convert_curr_iso_fdx
+from .fedex_request import FedexRequest
+
+pdf = tools.pdf
+_logger = logging.getLogger(__name__)
+
+
+class DeliveryFedex(models.Model):
+ _inherit = 'delivery.carrier'
+
+ fedex_service_type = fields.Selection(selection_add=[
+ ('GROUND_HOME_DELIVERY', 'GROUND_HOME_DELIVERY'),
+ # ('FEDEX_EXPRESS_SAVER', 'FEDEX_EXPRESS_SAVER'), # included in 13.0, ensure it stays there...
+ ])
+
+ 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:
+ if not third_party_account.delivery_type == 'fedex':
+ raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
+ return True
+ return False
+
+ def _get_fedex_payment_account_number(self, order=None, picking=None):
+ """
+ Common hook to customize what Fedex Account number to use.
+ :return: FedEx Account Number
+ """
+ # Provided by Hibou Odoo Suite `delivery_hibou`
+ third_party_account = self.get_third_party_account(order=order, picking=picking)
+ if third_party_account:
+ 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):
+ if order:
+ # third_party_account = self.get_third_party_account(order=order, picking=picking)
+ # if third_party_account:
+ # if not third_party_account.delivery_type == 'fedex':
+ # raise ValidationError('Non-FedEx Shipping Account indicated during FedEx shipment.')
+ # return third_party_account.name
+ if order.warehouse_id.fedex_account_number:
+ return order.warehouse_id.fedex_account_number
+ return self.fedex_account_number
+ if picking:
+ if 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_meter_number(self, order=None, picking=None):
+ if order:
+ if order.warehouse_id.fedex_meter_number:
+ return order.warehouse_id.fedex_meter_number
+ return self.fedex_meter_number
+ if picking:
+ if picking.picking_type_id.warehouse_id.fedex_meter_number:
+ return picking.picking_type_id.warehouse_id.fedex_meter_number
+ return self.fedex_meter_number
+
+ def _get_fedex_recipient_is_residential(self, partner):
+ if self.fedex_service_type.find('HOME') >= 0:
+ return True
+ return not (partner.is_company or partner.parent_id.is_company)
+
+ """
+ Overrides to use Hibou Delivery methods to get shipper etc. and to add 'transit_days' to result.
+ """
+ def fedex_rate_shipment(self, order):
+ max_weight = self._fedex_convert_weight(self.fedex_default_package_type_id.max_weight, self.fedex_weight_unit)
+ price = 0.0
+ is_india = order.partner_shipping_id.country_id.code == 'IN' and order.company_id.partner_id.country_id.code == 'IN'
+
+ # Estimate weight of the sales order; will be definitely recomputed on the picking field "weight"
+ est_weight_value = sum([(line.product_id.weight * line.product_uom_qty) for line in order.order_line if not line.display_type]) or 0.0
+ weight_value = self._fedex_convert_weight(est_weight_value, self.fedex_weight_unit)
+
+ # 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)
+
+ order_currency = order.currency_id
+ superself = self.sudo()
+
+ # Hibou Delivery methods for collecting details in an overridable way
+ shipper_company = superself.get_shipper_company(order=order)
+ shipper_warehouse = superself.get_shipper_warehouse(order=order)
+ recipient = superself.get_recipient(order=order)
+ acc_number = superself._get_fedex_account_number(order=order)
+ meter_number = superself._get_fedex_meter_number(order=order)
+ order_name = superself.get_order_name(order=order)
+ residential = self._get_fedex_recipient_is_residential(recipient)
+ date_planned = None
+ if self.env.context.get('date_planned'):
+ date_planned = self.env.context.get('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)
+ srm.shipment_request(
+ self.fedex_droppoff_type,
+ self.fedex_service_type,
+ self.fedex_default_package_type_id.shipper_package_code,
+ self.fedex_weight_unit,
+ self.fedex_saturday_delivery,
+ )
+ pkg = self.fedex_default_package_type_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 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.packaging_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.packaging_length,
+ sequence_number=total_package,
+ mode='rating',
+ )
+ srm.set_master_package(weight_value, total_package)
+ else:
+ srm.add_package(
+ weight_value,
+ package_code=pkg.shipper_package_code,
+ package_height=pkg.height,
+ package_width=pkg.width,
+ package_length=pkg.packaging_length,
+ mode='rating',
+ )
+ srm.set_master_package(weight_value, 1)
+
+ # Commodities for customs declaration (international shipping)
+ 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'] and not l.display_type):
+ commodity_amount = line.price_reduce_taxinc
+ 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'
+ commodity_harmonized_code = line.product_id.hs_code or ''
+ 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, acc_number, superself.fedex_duty_payment)
+
+ request = srm.rate(date_planned=date_planned)
+
+ 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:
+ return {'success': False,
+ 'price': 0.0,
+ 'error_message': _('Error:\n%s') % request['errors_message'],
+ 'warning_message': False}
+
+ date_delivered = request.get('date_delivered', False)
+ if date_delivered and 'delivery_calendar_id' in self and self.delivery_calendar_id.tz:
+ tz = pytz.timezone(self.delivery_calendar_id.tz)
+ date_delivered = tz.localize(date_delivered).astimezone(pytz.utc).replace(tzinfo=None)
+ return {'success': True,
+ 'price': price,
+ 'error_message': False,
+ 'transit_days': request.get('transit_days', False),
+ 'date_delivered': date_delivered,
+ 'warning_message': _('Warning:\n%s') % warnings if warnings else False}
+
+ """
+ Overrides to use Hibou Delivery methods to get shipper etc. and add insurance.
+ """
+ def fedex_send_shipping(self, pickings):
+ res = []
+
+ for picking in pickings:
+
+ 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)
+ acc_number = superself._get_fedex_account_number(picking=picking)
+ meter_number = superself._get_fedex_meter_number(picking=picking)
+ 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)
+ residential = self._get_fedex_recipient_is_residential(recipient)
+
+ srm.web_authentication_detail(superself.fedex_developer_key, superself.fedex_developer_password)
+ srm.client_detail(acc_number, meter_number)
+
+ srm.transaction_detail(picking.id)
+
+ package_type = picking_packages and picking_packages[0].package_type_id.shipper_package_code or self.fedex_default_package_type_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)
+ srm.set_recipient(recipient, attn=attn, residential=residential)
+
+ srm.shipping_charges_payment(payment_acc_number, third_party=bool(self.get_third_party_account(picking=picking)))
+
+ srm.shipment_label('COMMON2D', self.fedex_label_file_type, self.fedex_label_stock_type, 'TOP_EDGE_OF_TEXT_FIRST', 'SHIPPING_LABEL_FIRST')
+
+ order = picking.sale_id
+ company = shipper_company
+ order_currency = picking.sale_id.currency_id or picking.company_id.currency_id
+
+ net_weight = self._fedex_convert_weight(picking.shipping_weight, self.fedex_weight_unit)
+
+ # Commodities for customs declaration (international shipping)
+ if self.fedex_service_type in ['INTERNATIONAL_ECONOMY', 'INTERNATIONAL_PRIORITY'] or (picking.partner_id.country_id.code == 'IN' and picking.picking_type_id.warehouse_id.partner_id.country_id.code == 'IN'):
+
+ commodity_currency = order_currency
+ total_commodities_amount = 0.0
+ commodity_country_of_manufacture = picking.picking_type_id.warehouse_id.partner_id.country_id.code
+
+ for operation in picking.move_line_ids:
+ commodity_amount = operation.move_id.sale_line_id.price_reduce_taxinc or operation.product_id.lst_price
+ total_commodities_amount += (commodity_amount * operation.qty_done)
+ commodity_description = operation.product_id.name
+ commodity_number_of_piece = '1'
+ commodity_weight_units = self.fedex_weight_unit
+ commodity_weight_value = self._fedex_convert_weight(operation.product_id.weight * operation.qty_done, self.fedex_weight_unit)
+ commodity_quantity = operation.qty_done
+ commodity_quantity_units = 'EA'
+ commodity_harmonized_code = operation.product_id.hs_code or ''
+ srm.commodities(_convert_curr_iso_fdx(commodity_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(commodity_currency.name), total_commodities_amount, "NON_DOCUMENTS")
+ srm.duties_payment(shipper_warehouse, acc_number, superself.fedex_duty_payment)
+ send_etd = superself.env['ir.config_parameter'].get_param("delivery_fedex.send_etd")
+ srm.commercial_invoice(self.fedex_document_stock_type, send_etd)
+
+ package_count = len(picking_packages) or 1
+
+ # For india picking courier is not accepted without this details in label.
+ po_number = order.display_name or False
+ dept_number = False
+ if picking.partner_id.country_id.code == 'IN' and picking.picking_type_id.warehouse_id.partner_id.country_id.code == 'IN':
+ po_number = 'B2B' if picking.partner_id.commercial_partner_id.is_company else 'B2C'
+ dept_number = 'BILL D/T: SENDER'
+
+ # TODO RIM master: factorize the following crap
+
+ ################
+ # Multipackage #
+ ################
+ if package_count > 1:
+
+ # Note: Fedex has a complex multi-piece shipping interface
+ # - Each package has to be sent in a separate request
+ # - First package is called "master" package and holds shipping-
+ # related information, including addresses, customs...
+ # - Last package responses contains shipping price and code
+ # - If a problem happens with a package, every previous package
+ # of the shipping has to be cancelled separately
+ # (Why doing it in a simple way when the complex way exists??)
+
+ master_tracking_id = False
+ package_labels = []
+ carrier_tracking_ref = ""
+
+ for sequence, package in enumerate(picking_packages, start=1):
+
+ package_weight = self._fedex_convert_weight(package.shipping_weight, self.fedex_weight_unit)
+ packaging = package.package_type_id
+ packaging_code = packaging.shipper_package_code if (packaging.package_carrier_type == 'fedex' and packaging.shipper_package_code) else self.fedex_default_package_type_id.shipper_package_code
+
+ # Hibou Delivery
+ # Add more details to package.
+ srm._add_package(
+ package_weight,
+ package_code=packaging_code,
+ package_height=packaging.height,
+ package_width=packaging.width,
+ package_length=packaging.packaging_length,
+ sequence_number=sequence,
+ po_number=po_number,
+ dept_number=dept_number,
+ # reference=picking.display_name,
+ reference=('%s-%d' % (order_name, sequence)), # above "reference" is new in 13.0, using new name but old value
+ 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
+
+ warnings = request.get('warnings_message')
+ if warnings:
+ _logger.info(warnings)
+
+ # First package
+ if sequence == 1:
+ if not request.get('errors_message'):
+ master_tracking_id = request['master_tracking_id']
+ package_labels.append((package_name, srm.get_label()))
+ carrier_tracking_ref = request['tracking_number']
+ else:
+ raise UserError(request['errors_message'])
+
+ # Intermediary packages
+ elif sequence > 1 and sequence < package_count:
+ if not request.get('errors_message'):
+ package_labels.append((package_name, srm.get_label()))
+ carrier_tracking_ref = carrier_tracking_ref + "," + request['tracking_number']
+ else:
+ raise UserError(request['errors_message'])
+
+ # Last package
+ elif sequence == package_count:
+ # recuperer le label pdf
+ if not request.get('errors_message'):
+ package_labels.append((package_name, srm.get_label()))
+
+ if _convert_curr_iso_fdx(order_currency.name) in request['price']:
+ carrier_price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
+ else:
+ _logger.info("Preferred currency has not been found in FedEx response")
+ company_currency = picking.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)]
+ carrier_price = company_currency._convert(
+ amount, order_currency, company, order.date_order or fields.Date.today())
+ else:
+ amount = request['price']['USD']
+ carrier_price = company_currency._convert(
+ amount, order_currency, company, order.date_order or fields.Date.today())
+
+ carrier_tracking_ref = carrier_tracking_ref + "," + request['tracking_number']
+
+ logmessage = _("Shipment created into Fedex "
+ "Tracking Numbers: %s "
+ "Packages: %s") % (carrier_tracking_ref, ','.join([pl[0] for pl in package_labels]))
+ if self.fedex_label_file_type != 'PDF':
+ attachments = [('LabelFedex-%s.%s' % (pl[0], self.fedex_label_file_type), pl[1]) for pl in package_labels]
+ if self.fedex_label_file_type == 'PDF':
+ attachments = [('LabelFedex.pdf', pdf.merge_pdf([pl[1] for pl in package_labels]))]
+ picking.message_post(body=logmessage, attachments=attachments)
+ shipping_data = {'exact_price': carrier_price,
+ 'tracking_number': carrier_tracking_ref}
+ res = res + [shipping_data]
+ else:
+ raise UserError(request['errors_message'])
+
+ # TODO RIM handle if a package is not accepted (others should be deleted)
+
+ ###############
+ # One package #
+ ###############
+ elif package_count == 1:
+ packaging = picking_packages[:1].package_type_id or self.fedex_default_package_type_id
+ packaging_code = packaging.shipper_package_code if packaging.package_carrier_type == 'fedex' else self.fedex_default_package_type_id.shipper_package_code
+ srm._add_package(
+ net_weight,
+ package_code=packaging_code,
+ package_height=packaging.height,
+ package_width=packaging.width,
+ package_length=packaging.packaging_length,
+ po_number=po_number,
+ dept_number=dept_number,
+ # reference=picking.display_name,
+ reference=order_name, # above "reference" is new in 13.0, using new name but old value
+ 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
+ request = srm.process_shipment()
+
+ 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']:
+ carrier_price = request['price'][_convert_curr_iso_fdx(order_currency.name)]
+ else:
+ _logger.info("Preferred currency has not been found in FedEx response")
+ company_currency = picking.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)]
+ carrier_price = company_currency._convert(
+ amount, order_currency, company, order.date_order or fields.Date.today())
+ else:
+ amount = request['price']['USD']
+ carrier_price = company_currency._convert(
+ amount, order_currency, company, order.date_order or fields.Date.today())
+
+ carrier_tracking_ref = request['tracking_number']
+ logmessage = (_("Shipment created into Fedex Tracking Number : %s") % (carrier_tracking_ref))
+
+ fedex_labels = [('LabelFedex-%s-%s.%s' % (carrier_tracking_ref, index, self.fedex_label_file_type), label)
+ for index, label in enumerate(srm._get_labels(self.fedex_label_file_type))]
+ picking.message_post(body=logmessage, attachments=fedex_labels)
+ shipping_data = {'exact_price': carrier_price,
+ 'tracking_number': carrier_tracking_ref}
+ res = res + [shipping_data]
+ else:
+ raise UserError(request['errors_message'])
+
+ ##############
+ # No package #
+ ##############
+ else:
+ raise UserError(_('No packages for this picking'))
+ if self.return_label_on_delivery:
+ self.get_return_label(picking, tracking_number=request['tracking_number'], origin_date=request['date'])
+ commercial_invoice = srm.get_document()
+ if commercial_invoice:
+ fedex_documents = [('DocumentFedex.pdf', commercial_invoice)]
+ picking.message_post(body='Fedex Documents', attachments=fedex_documents)
+ 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_package_type_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')
+
+ # 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_package_type_id.shipper_package_code,
+ self.fedex_weight_unit,
+ self.fedex_saturday_delivery,
+ ship_timestamp=date_planned,
+ )
+ pkg = self.fedex_default_package_type_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.packaging_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.packaging_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.packaging_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.package_type_id
+ package_code = package.package_type_id.shipper_package_code if packaging.package_carrier_type == 'fedex' else self.fedex_default_package_type_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.packaging_length,
+ sequence_number=1,
+ # po_number=po_number,
+ # dept_number=dept_number,
+ reference=('%s-%d' % (order_name, 1)),
+ insurance=insurance_value,
+ signature_required=signature_required
+ )
+ srm.set_master_package(package_weight, 1)
+ else:
+ # deliver all together...
+ package_weight = self._fedex_convert_weight(picking.shipping_weight or picking.weight, self.fedex_weight_unit)
+ packaging = self.fedex_default_package_type_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.packaging_length,
+ sequence_number=1,
+ # po_number=po_number,
+ # dept_number=dept_number,
+ reference=('%s-%d' % (order_name, 1)),
+ insurance=insurance_value,
+ signature_required=signature_required
+ )
+ srm.set_master_package(package_weight, 1)
+
+
+ # 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)
+ if date_delivered:
+ tz = pytz.timezone(self.delivery_calendar_id.tz)
+ date_delivered = tz.localize(date_delivered).astimezone(pytz.utc).replace(tzinfo=None)
+ 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):
+ # Overriddent to use the correct account numbers for cancelling
+ 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 #%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
new file mode 100644
index 00000000..483c26a9
--- /dev/null
+++ b/delivery_fedex_hibou/models/fedex_request.py
@@ -0,0 +1,268 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from zeep.exceptions import Fault
+from datetime import datetime
+from copy import deepcopy
+from odoo.addons.delivery_fedex.models import fedex_request
+from odoo.tools import remove_accents
+
+STATECODE_REQUIRED_COUNTRIES = fedex_request.STATECODE_REQUIRED_COUNTRIES
+
+
+def sanitize_name(name):
+ if isinstance(name, str):
+ return name.replace('[', '').replace(']', '')
+ return 'Unknown'
+
+
+class FedexRequest(fedex_request.FedexRequest):
+ _transit_days = {
+ 'ONE_DAYS': 1,
+ 'ONE_DAY': 1,
+ 'TWO_DAYS': 2,
+ 'THREE_DAYS': 3,
+ 'FOUR_DAYS': 4,
+ 'FIVE_DAYS': 5,
+ 'SIX_DAYS': 6,
+ 'SEVEN_DAYS': 7,
+ 'EIGHT_DAYS': 8,
+ 'NINE_DAYS': 9,
+ 'TEN_DAYS': 10,
+ }
+
+ _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,
+ }
+
+ def set_recipient(self, recipient_partner, attn=None, residential=False):
+ """
+ Adds ATTN: and sanitizes against known 'illegal' common characters in names.
+ :param recipient_partner: default
+ :param attn: NEW add to contact name as an ' ATTN: $attn'
+ :param residential: NEW allow ground home delivery
+ :return:
+ """
+ Contact = self.factory.Contact()
+ if recipient_partner.is_company:
+ Contact.PersonName = ''
+ Contact.CompanyName = sanitize_name(remove_accents(recipient_partner.name))
+ else:
+ Contact.PersonName = sanitize_name(remove_accents(recipient_partner.name))
+ Contact.CompanyName = sanitize_name(remove_accents(recipient_partner.parent_id.name)) or ''
+
+ if attn:
+ Contact.PersonName = Contact.PersonName + ' ATTN: ' + str(attn)
+
+ Contact.PhoneNumber = recipient_partner.phone or ''
+
+ Address = self.factory.Address()
+ Address.StreetLines = [remove_accents(recipient_partner.street) or '', remove_accents(recipient_partner.street2) or '']
+ Address.City = remove_accents(recipient_partner.city) or ''
+ if recipient_partner.country_id.code in STATECODE_REQUIRED_COUNTRIES:
+ Address.StateOrProvinceCode = recipient_partner.state_id.code or ''
+ else:
+ Address.StateOrProvinceCode = ''
+ Address.PostalCode = recipient_partner.zip or ''
+ Address.CountryCode = recipient_partner.country_id.code or ''
+
+ if residential:
+ Address.Residential = True
+
+ self.RequestedShipment.Recipient = self.factory.Party()
+ self.RequestedShipment.Recipient.Contact = Contact
+ self.RequestedShipment.Recipient.Address = Address
+
+ def add_package(self, weight_value, package_code=False, package_height=0, package_width=0, package_length=0, sequence_number=False, mode='shipping', reference=False, insurance=False, signature_required=False):
+ # TODO remove in master and change the signature of a public method
+ return self._add_package(weight_value=weight_value, package_code=package_code, package_height=package_height, package_width=package_width,
+ package_length=package_length, sequence_number=sequence_number, mode=mode, po_number=False, dept_number=False, reference=reference, insurance=insurance, signature_required=signature_required)
+
+ def _add_package(self, weight_value, package_code=False, package_height=0, package_width=0, package_length=0, sequence_number=False, mode='shipping', po_number=False, dept_number=False, reference=False, insurance=False, signature_required=False):
+ package = self.factory.RequestedPackageLineItem()
+ package_weight = self.factory.Weight()
+ package_weight.Value = weight_value
+ package_weight.Units = self.RequestedShipment.TotalWeight.Units
+
+ if insurance:
+ insured = self.factory.Money()
+ insured.Amount = insurance
+ # TODO at some point someone might need currency here
+ insured.Currency = 'USD'
+ package.InsuredValue = insured
+
+ special_service = self.factory.PackageSpecialServicesRequested()
+ signature_detail = self.factory.SignatureOptionDetail()
+ signature_detail.OptionType = 'DIRECT' if signature_required else 'NO_SIGNATURE_REQUIRED'
+ special_service.SignatureOptionDetail = signature_detail
+ package.SpecialServicesRequested = special_service
+
+ package.PhysicalPackaging = 'BOX'
+ if package_code == 'YOUR_PACKAGING':
+ package.Dimensions = self.factory.Dimensions()
+ package.Dimensions.Height = package_height
+ package.Dimensions.Width = package_width
+ package.Dimensions.Length = package_length
+ # TODO in master, add unit in product packaging and perform unit conversion
+ package.Dimensions.Units = "IN" if self.RequestedShipment.TotalWeight.Units == 'LB' else 'CM'
+ if po_number:
+ po_reference = self.factory.CustomerReference()
+ po_reference.CustomerReferenceType = 'P_O_NUMBER'
+ po_reference.Value = po_number
+ package.CustomerReferences.append(po_reference)
+ if dept_number:
+ dept_reference = self.factory.CustomerReference()
+ dept_reference.CustomerReferenceType = 'DEPARTMENT_NUMBER'
+ dept_reference.Value = dept_number
+ package.CustomerReferences.append(dept_reference)
+ if reference:
+ customer_reference = self.factory.CustomerReference()
+ customer_reference.CustomerReferenceType = 'CUSTOMER_REFERENCE'
+ customer_reference.Value = reference
+ package.CustomerReferences.append(customer_reference)
+
+ package.Weight = package_weight
+ if mode == 'rating':
+ package.GroupPackageCount = 1
+ if sequence_number:
+ package.SequenceNumber = sequence_number
+ else:
+ self.hasOnePackage = True
+
+ if mode == 'rating':
+ self.RequestedShipment.RequestedPackageLineItems.append(package)
+ else:
+ self.RequestedShipment.RequestedPackageLineItems = package
+
+ def shipping_charges_payment(self, shipping_charges_payment_account, third_party=False):
+ """
+ Allow 'shipping_charges_payment_account' to be considered 'third_party'
+ :param shipping_charges_payment_account: default
+ :param third_party: NEW add to indicate that the 'shipping_charges_payment_account' is third party.
+ :return:
+ """
+ self.RequestedShipment.ShippingChargesPayment = self.factory.Payment()
+ self.RequestedShipment.ShippingChargesPayment.PaymentType = 'SENDER' if not third_party else 'THIRD_PARTY'
+ Payor = self.factory.Payor()
+ Payor.ResponsibleParty = self.factory.Party()
+ 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.factory.RequestedShipment()
+ self.RequestedShipment.SpecialServicesRequested = self.factory.ShipmentSpecialServicesRequested()
+ 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 = self.factory.Weight()
+ 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'):
+ self.RequestedShipment.SpecialServicesRequested.SpecialServiceTypes.append('SATURDAY_DELIVERY')
+
+ # Rating stuff
+
+ 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 self.hasCommodities:
+ self.RequestedShipment.CustomsClearanceDetail.Commodities = self.listCommodities
+
+ try:
+ self.response = self.client.service.getRates(WebAuthenticationDetail=self.WebAuthenticationDetail,
+ ClientDetail=self.ClientDetail,
+ TransactionDetail=self.TransactionDetail,
+ Version=self.VersionId,
+ RequestedShipment=self.RequestedShipment,
+ ReturnTransitAndCommit=True) # New ReturnTransitAndCommit for CommitDetails in response
+ 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] = float(rating.ShipmentRateDetail.TotalNetFedExCharge.Amount)
+ if len(self.response.RateReplyDetails[0].RatedShipmentDetails) == 1:
+ if 'CurrencyExchangeRate' in self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail and self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail['CurrencyExchangeRate']:
+ formatted_response['price'][self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = float(self.response.RateReplyDetails[0].RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount) / float(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] = float(rating.ShipmentRateDetail.TotalNetFedExCharge.Amount)
+ if len(rate_reply_detail.RatedShipmentDetails) == 1:
+ if 'CurrencyExchangeRate' in rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail and rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail['CurrencyExchangeRate']:
+ res['price'][rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail.CurrencyExchangeRate.FromCurrency] = float(rate_reply_detail.RatedShipmentDetails[0].ShipmentRateDetail.TotalNetFedExCharge.Amount) / float(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')])
+ formatted_response['errors_message'] = errors_message
+
+ if any([n.Severity == 'WARNING' for n in self.response.Notifications]):
+ warnings_message = '\n'.join([("%s: %s" % (n.Code, n.Message)) for n in self.response.Notifications if n.Severity == 'WARNING'])
+ formatted_response['warnings_message'] = warnings_message
+
+ except Fault as fault:
+ 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
new file mode 100644
index 00000000..58726f6f
--- /dev/null
+++ b/delivery_fedex_hibou/models/stock.py
@@ -0,0 +1,10 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class StockWarehouse(models.Model):
+ _inherit = 'stock.warehouse'
+
+ fedex_account_number = fields.Char(string='FedEx Account Number')
+ fedex_meter_number = fields.Char(string='FedEx Meter Number')
diff --git a/delivery_fedex_hibou/views/stock_views.xml b/delivery_fedex_hibou/views/stock_views.xml
new file mode 100644
index 00000000..8df21336
--- /dev/null
+++ b/delivery_fedex_hibou/views/stock_views.xml
@@ -0,0 +1,14 @@
+
+
+
+ stock.warehouse
+ stock.warehouse
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/delivery_gso/__manifest__.py b/delivery_gso/__manifest__.py
index e1923d56..26968b3a 100644
--- a/delivery_gso/__manifest__.py
+++ b/delivery_gso/__manifest__.py
@@ -1,7 +1,7 @@
{
'name': 'Golden State Overnight (gso.com) Shipping',
'summary': 'Send your shippings through gso.com and track them online.',
- 'version': '15.0.1.0.0',
+ 'version': '15.0.1.0.1',
'author': "Hibou Corp.",
'category': 'Warehouse',
'license': 'OPL-1',
diff --git a/delivery_gso/models/delivery_gso.py b/delivery_gso/models/delivery_gso.py
index a242ff85..cc3a6965 100644
--- a/delivery_gso/models/delivery_gso.py
+++ b/delivery_gso/models/delivery_gso.py
@@ -196,14 +196,6 @@ 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': [],
@@ -218,6 +210,20 @@ class ProviderGSO(models.Model):
if picking_packages:
# Every package will be a transaction
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
@@ -236,9 +242,10 @@ class ProviderGSO(models.Model):
raise ValidationError(e)
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)
@@ -393,7 +400,7 @@ class ProviderGSO(models.Model):
elif not package:
est_weight_value = self._gso_convert_weight(picking.shipping_weight)
else:
- est_weight_value = package.shipping_weight or package.weight
+ est_weight_value = self._gso_convert_weight(package.shipping_weight or package.weight)
request_body = {
'AccountNumber': sudoself.gso_account_number,
@@ -409,7 +416,7 @@ class ProviderGSO(models.Model):
result = service.get_rates_and_transit_time(request_body)
# _logger.warning('GSO result:\n%s' % result)
except HTTPError as e:
- _logger.error(e)
+ # _logger.error(e)
return [{
'success': False,
'price': 0.0,
diff --git a/delivery_hibou/__manifest__.py b/delivery_hibou/__manifest__.py
index 62d37108..96441511 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': '15.0.1.0.0',
+ 'version': '15.0.1.1.0',
'author': "Hibou Corp.",
'category': 'Stock',
'license': 'LGPL-3',
diff --git a/delivery_hibou/models/delivery.py b/delivery_hibou/models/delivery.py
index 85f1f931..c604c02a 100644
--- a/delivery_hibou/models/delivery.py
+++ b/delivery_hibou/models/delivery.py
@@ -1,4 +1,5 @@
-from odoo import fields, models, _
+from odoo import api, fields, models, _
+from odoo.tools.float_utils import float_compare
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
from odoo.exceptions import UserError
@@ -9,6 +10,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 '
@@ -16,21 +20,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
@@ -202,9 +227,9 @@ class DeliveryCarrier(models.Model):
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.package_type_id.package_carrier_type in (False, '', 'none', carrier.delivery_type))
+ 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.package_type_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):
@@ -243,3 +268,70 @@ class DeliveryCarrier(models.Model):
})
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')
+ package_require_insurance = fields.Boolean(string='Require Insurance')
+ package_require_signature = fields.Boolean(string='Require Signature')
+
+ @api.model
+ def default_get(self, fields_list):
+ defaults = super().default_get(fields_list)
+ if 'package_declared_value' in fields_list:
+ picking = self.env['stock.picking'].browse(defaults.get('picking_id'))
+ move_line_ids = picking.move_line_ids.filtered(lambda m:
+ float_compare(m.qty_done, 0.0, precision_rounding=m.product_uom_id.rounding) > 0
+ and not m.result_package_id
+ )
+ total_value = 0.0
+ for ml in move_line_ids:
+ qty = ml.product_uom_id._compute_quantity(ml.qty_done, ml.product_id.uom_id)
+ total_value += qty * ml.product_id.standard_price
+ defaults['package_declared_value'] = total_value
+ return defaults
+
+ @api.onchange('package_declared_value')
+ def _onchange_package_declared_value(self):
+ picking = self.picking_id
+ 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 action_put_in_pack(self):
+ # Copied because `delivery_package` is not retained by reference or returned...
+ picking_move_lines = self.picking_id.move_line_ids
+ if not self.picking_id.picking_type_id.show_reserved and not self.env.context.get('barcode_view'):
+ picking_move_lines = self.picking_id.move_line_nosuggest_ids
+
+ move_line_ids = picking_move_lines.filtered(lambda ml:
+ float_compare(ml.qty_done, 0.0,
+ precision_rounding=ml.product_uom_id.rounding) > 0
+ and not ml.result_package_id
+ )
+ if not move_line_ids:
+ move_line_ids = picking_move_lines.filtered(lambda ml: float_compare(ml.product_uom_qty, 0.0,
+ precision_rounding=ml.product_uom_id.rounding) > 0 and float_compare(
+ ml.qty_done, 0.0,
+ precision_rounding=ml.product_uom_id.rounding) == 0)
+
+ delivery_package = self.picking_id._put_in_pack(move_line_ids)
+ # write shipping weight and package type on 'stock_quant_package' if needed
+ if self.delivery_package_type_id:
+ delivery_package.package_type_id = self.delivery_package_type_id
+ if self.shipping_weight:
+ delivery_package.shipping_weight = self.shipping_weight
+ # Hibou : Fill additional fields.
+ delivery_package.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 9f732275..77ae28b1 100644
--- a/delivery_hibou/models/stock.py
+++ b/delivery_hibou/models/stock.py
@@ -7,6 +7,9 @@ class StockQuantPackage(models.Model):
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')
@@ -34,7 +37,14 @@ 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')
+ commercial_partner_id = fields.Many2one('res.partner', related='partner_id.commercial_partner_id')
@api.depends('package_ids.carrier_tracking_ref')
def _compute_package_carrier_tracking_ref(self):
@@ -67,8 +77,10 @@ class StockPicking(models.Model):
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
@@ -112,6 +124,8 @@ class StockPicking(models.Model):
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
@@ -150,9 +164,10 @@ class StockPicking(models.Model):
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)
+ # Above cancel should also say which are cancelled in chatter.
+ # 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
diff --git a/delivery_hibou/views/delivery_views.xml b/delivery_hibou/views/delivery_views.xml
index 681578aa..08fe2010 100644
--- a/delivery_hibou/views/delivery_views.xml
+++ b/delivery_hibou/views/delivery_views.xml
@@ -7,6 +7,7 @@
+
@@ -20,6 +21,11 @@
[]
+
+
+
+
+
diff --git a/delivery_hibou/views/stock_views.xml b/delivery_hibou/views/stock_views.xml
index 6bb47f34..0989f519 100644
--- a/delivery_hibou/views/stock_views.xml
+++ b/delivery_hibou/views/stock_views.xml
@@ -17,6 +17,9 @@
+
+
+
@@ -28,8 +31,12 @@
+
-
+
+
diff --git a/sale_planner/__init__.py b/sale_planner/__init__.py
index 134df274..b0eafef5 100644
--- a/sale_planner/__init__.py
+++ b/sale_planner/__init__.py
@@ -1,2 +1,4 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
from . import wizard
from . import models
diff --git a/sale_planner/__manifest__.py b/sale_planner/__manifest__.py
index c87d85f2..444a5da7 100644
--- a/sale_planner/__manifest__.py
+++ b/sale_planner/__manifest__.py
@@ -1,10 +1,10 @@
{
'name': 'Sale Order Planner',
'summary': 'Plans order dates and warehouses.',
- 'version': '15.0.1.0.0',
+ 'version': '15.0.2.0.0',
'author': "Hibou Corp.",
'category': 'Sale',
- 'license': 'AGPL-3',
+ 'license': 'OPL-1',
'complexity': 'expert',
'images': [],
'website': "https://hibou.io",
@@ -37,6 +37,7 @@ on the specific method's characteristics. (e.g. Do they deliver on Saturday?)
'views/stock.xml',
'views/delivery.xml',
'views/product.xml',
+ 'views/res_config_settings_views.xml',
],
'auto_install': False,
'installable': True,
diff --git a/sale_planner/models/__init__.py b/sale_planner/models/__init__.py
index ff524f40..95b4955a 100644
--- a/sale_planner/models/__init__.py
+++ b/sale_planner/models/__init__.py
@@ -1,3 +1,5 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
from . import delivery
from . import partner
from . import planning
@@ -5,3 +7,4 @@ from . import product
from . import resource
from . import sale
from . import stock
+from . import res_config_settings
diff --git a/sale_planner/models/delivery.py b/sale_planner/models/delivery.py
index c8526c34..dc66ab81 100644
--- a/sale_planner/models/delivery.py
+++ b/sale_planner/models/delivery.py
@@ -1,3 +1,5 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
from datetime import timedelta
from odoo import api, fields, models
@@ -61,6 +63,9 @@ class DeliveryCarrier(models.Model):
def calculate_transit_days(self, date_planned, date_delivered):
self.ensure_one()
+ if not self.delivery_calendar_id:
+ return 0
+
if isinstance(date_planned, str):
date_planned = fields.Datetime.from_string(date_planned)
if isinstance(date_delivered, str):
diff --git a/sale_planner/models/partner.py b/sale_planner/models/partner.py
index a87ff4f0..a5c8a39e 100644
--- a/sale_planner/models/partner.py
+++ b/sale_planner/models/partner.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
try:
diff --git a/sale_planner/models/planning.py b/sale_planner/models/planning.py
index 47d645af..54969669 100644
--- a/sale_planner/models/planning.py
+++ b/sale_planner/models/planning.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/sale_planner/models/product.py b/sale_planner/models/product.py
index 55cb677e..add93077 100644
--- a/sale_planner/models/product.py
+++ b/sale_planner/models/product.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/sale_planner/models/res_config_settings.py b/sale_planner/models/res_config_settings.py
new file mode 100644
index 00000000..06e2f899
--- /dev/null
+++ b/sale_planner/models/res_config_settings.py
@@ -0,0 +1,79 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+def sale_planner_warehouse_ids(env, company):
+ get_param = env['ir.config_parameter'].sudo().get_param
+ warehouse_ids = get_param('sale.planner.warehouse_ids.%s' % (company.id, )) or []
+ if warehouse_ids and isinstance(warehouse_ids, str):
+ try:
+ warehouse_ids = [int(i) for i in warehouse_ids.split(',')]
+ except:
+ warehouse_ids = []
+ return warehouse_ids
+
+
+def sale_planner_carrier_ids(env, company):
+ get_param = env['ir.config_parameter'].sudo().get_param
+ carrier_ids = get_param('sale.planner.carrier_ids.%s' % (company.id, )) or []
+ if carrier_ids and isinstance(carrier_ids, str):
+ try:
+ carrier_ids = [int(c) for c in carrier_ids.split(',')]
+ except:
+ carrier_ids = []
+ return carrier_ids
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ sale_planner_warehouse_ids = fields.Many2many('stock.warehouse',
+ string='Sale Order Planner Warehouses',
+ compute='_compute_sale_planner_warehouse_ids',
+ inverse='_inverse_sale_planner_warehouse_ids')
+ sale_planner_carrier_ids = fields.Many2many('delivery.carrier',
+ string='Sale Order Planner Carriers',
+ compute='_compute_sale_planner_carrier_ids',
+ inverse='_inverse_sale_planner_carrier_ids')
+
+ def _compute_sale_planner_warehouse_ids_ids(self):
+ company = self.company_id or self.env.user.company_id
+ return sale_planner_warehouse_ids(self.env, company)
+
+ def _compute_sale_planner_carrier_ids_ids(self):
+ company = self.company_id or self.env.user.company_id
+ return sale_planner_carrier_ids(self.env, company)
+
+ def _compute_sale_planner_warehouse_ids(self):
+ for settings in self:
+ warehouse_ids = settings._compute_sale_planner_warehouse_ids_ids()
+ warehouses = self.env['stock.warehouse'].browse(warehouse_ids)
+ settings.sale_planner_warehouse_ids = warehouses
+
+ def _compute_sale_planner_carrier_ids(self):
+ for settings in self:
+ carrier_ids = settings._compute_sale_planner_carrier_ids_ids()
+ carriers = self.env['delivery.carrier'].browse(carrier_ids)
+ settings.sale_planner_carrier_ids = carriers
+
+ def _inverse_sale_planner_warehouse_ids(self):
+ set_param = self.env['ir.config_parameter'].sudo().set_param
+ company_id = self.company_id.id or self.env.user.company_id.id
+ for settings in self:
+ warehouse_ids = ','.join(str(i) for i in settings.sale_planner_warehouse_ids.ids)
+ set_param('sale.planner.warehouse_ids.%s' % (company_id, ), warehouse_ids)
+
+ def _inverse_sale_planner_carrier_ids(self):
+ set_param = self.env['ir.config_parameter'].sudo().set_param
+ company_id = self.company_id.id or self.env.user.company_id.id
+ for settings in self:
+ carrier_ids = ','.join(str(i) for i in settings.sale_planner_carrier_ids.ids)
+ set_param('sale.planner.carrier_ids.%s' % (company_id, ), carrier_ids)
+
+ @api.model
+ def get_values(self):
+ res = super(ResConfigSettings, self).get_values()
+ res['sale_planner_warehouse_ids'] = [(6, 0, self._compute_sale_planner_warehouse_ids_ids())]
+ res['sale_planner_carrier_ids'] = [(6, 0, self._compute_sale_planner_carrier_ids_ids())]
+ return res
diff --git a/sale_planner/models/sale.py b/sale_planner/models/sale.py
index 30396792..919de060 100644
--- a/sale_planner/models/sale.py
+++ b/sale_planner/models/sale.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/sale_planner/models/stock.py b/sale_planner/models/stock.py
index 4d72f4f4..10011e6c 100644
--- a/sale_planner/models/stock.py
+++ b/sale_planner/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
@@ -7,3 +9,7 @@ class Warehouse(models.Model):
shipping_calendar_id = fields.Many2one(
'resource.calendar', 'Shipping Calendar',
help="This calendar represents shipping availability from the warehouse.")
+ sale_planner_carrier_ids = fields.Many2many('delivery.carrier',
+ relation='sale_planner_carrier_wh_rel',
+ string='Sale Order Planner Base Carriers',
+ help='Overrides the global carriers.')
diff --git a/sale_planner/tests/__init__.py b/sale_planner/tests/__init__.py
index 25366b57..8208aa7b 100644
--- a/sale_planner/tests/__init__.py
+++ b/sale_planner/tests/__init__.py
@@ -1 +1,3 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
from . import test_planner
diff --git a/sale_planner/tests/test_planner.py b/sale_planner/tests/test_planner.py
index 4bc58ad8..5f746cc8 100644
--- a/sale_planner/tests/test_planner.py
+++ b/sale_planner/tests/test_planner.py
@@ -1,3 +1,5 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
from odoo.tests import common
from datetime import datetime, timedelta
from json import loads as json_decode
diff --git a/sale_planner/views/res_config_settings_views.xml b/sale_planner/views/res_config_settings_views.xml
new file mode 100644
index 00000000..69b215f7
--- /dev/null
+++ b/sale_planner/views/res_config_settings_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ res.config.settings.view.form.inherit
+ res.config.settings
+
+
+
+
+
Sale Order Planner
+
+
+
+
+
+ Add a carrier that represents the 'base rate' for a carrier's type.
+ For example, you should add 1 FedEx carrier here and let us build up the
+ rates for your other FedEx shipping methods.
+
+
+
+
+
+
+
+
+
+ Warehouses you typically ship inventory out of that you want to
+ include in the planning of sale orders.
+
+
+
+
+
+
+
+
+
diff --git a/sale_planner/views/stock.xml b/sale_planner/views/stock.xml
index d0195d86..81d0f78e 100644
--- a/sale_planner/views/stock.xml
+++ b/sale_planner/views/stock.xml
@@ -7,6 +7,7 @@
+
diff --git a/sale_planner/wizard/__init__.py b/sale_planner/wizard/__init__.py
index 235b12a8..48b8dc48 100644
--- a/sale_planner/wizard/__init__.py
+++ b/sale_planner/wizard/__init__.py
@@ -1 +1,3 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
from . import order_planner
diff --git a/sale_planner/wizard/order_planner.py b/sale_planner/wizard/order_planner.py
index 6a072c76..61e7ee0c 100644
--- a/sale_planner/wizard/order_planner.py
+++ b/sale_planner/wizard/order_planner.py
@@ -1,3 +1,5 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
from math import sin, cos, sqrt, atan2, radians
from json import dumps, loads
from copy import deepcopy
@@ -10,11 +12,12 @@ _logger = getLogger(__name__)
try:
from uszipcode import SearchEngine
except ImportError:
- _logger.warning('module "uszipcode" cannot be loaded, falling back to Google API')
+ _logger.warn('module "uszipcode" cannot be loaded, falling back to Google API')
SearchEngine = None
from odoo import api, fields, models
from odoo.tools.safe_eval import safe_eval
+from ..models.res_config_settings import sale_planner_warehouse_ids, sale_planner_carrier_ids
class FakeCollection():
@@ -26,7 +29,12 @@ class FakeCollection():
yield v
def filtered(self, f):
- return filter(f, self.vals)
+ return self.__class__([v for v in self.vals if f(v)])
+ # return filter(f, self.vals)
+
+ def mapped(self, s):
+ # note this only maps to one level and doesn't really support recordset
+ return [v[s] for v in self.vals]
def sudo(self, *args, **kwargs):
return self
@@ -178,6 +186,12 @@ class FakeSaleOrder(FakeCollection):
if item == '__last_update':
return str(datetime.now())
return getattr(self, item)
+
+ def _get_estimated_weight(self):
+ weight = 0.0
+ for order_line in self.order_line.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not l.is_delivery and not l.display_type):
+ weight += order_line.product_qty * order_line.product_id.weight
+ return weight
def distance(lat_1, lon_1, lat_2, lon_2):
@@ -308,15 +322,17 @@ class SaleOrderMakePlan(models.TransientModel):
domain.append(('company_id', 'in', self.env.context['allowed_company_ids']))
if self.env.context.get('warehouse_domain'):
+ if not domain:
+ domain = []
domain.extend(self.env.context.get('warehouse_domain'))
+ if domain:
+ return warehouse.search(domain)
- irconfig_parameter = self.env['ir.config_parameter'].sudo()
- if irconfig_parameter.get_param('sale.order.planner.warehouse_domain'):
- domain.extend(safe_eval(irconfig_parameter.get_param('sale.order.planner.warehouse_domain')))
+ # no domain, use global
+ warehouse_ids = sale_planner_warehouse_ids(self.env, self.env.user.company_id)
+ return warehouse.browse(warehouse_ids)
- return warehouse.search(domain)
-
- def get_shipping_carriers(self, carrier_id=None, domain=None):
+ def get_shipping_carriers(self, carrier_id=None, domain=None, warehouse_id=None):
Carrier = self.env['delivery.carrier'].sudo()
if carrier_id:
return Carrier.browse(carrier_id)
@@ -324,18 +340,21 @@ class SaleOrderMakePlan(models.TransientModel):
if domain:
if not isinstance(domain, (list, tuple)):
domain = safe_eval(domain)
- else:
- domain = []
if self.env.context.get('carrier_domain'):
- # potential bug here if this is textual
+ if not domain:
+ domain = []
domain.extend(self.env.context.get('carrier_domain'))
+ if domain:
+ return Carrier.search(domain)
- irconfig_parameter = self.env['ir.config_parameter'].sudo()
- if irconfig_parameter.get_param('sale.order.planner.carrier_domain'):
- domain.extend(safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain')))
-
- return Carrier.search(domain)
+ # no domain, use global
+ if warehouse_id:
+ warehouse = self.env['stock.warehouse'].sudo().browse(warehouse_id)
+ if warehouse.sale_planner_carrier_ids:
+ return warehouse.sale_planner_carrier_ids.sudo()
+ carrier_ids = sale_planner_carrier_ids(self.env, self.env.user.company_id)
+ return Carrier.browse(carrier_ids)
def _generate_base_option(self, order_fake, policy_group):
flag_force_closest = False
@@ -616,6 +635,8 @@ class SaleOrderMakePlan(models.TransientModel):
return self._find_closest_warehouse(warehouses, partner.partner_latitude, partner.partner_longitude)
def _find_closest_warehouse(self, warehouses, latitude, longitude):
+ if not warehouses:
+ return warehouses
distances = {distance(latitude, longitude, wh.partner_id.partner_latitude, wh.partner_id.partner_longitude): wh.id for wh in warehouses}
wh_id = distances[min(distances)]
return warehouses.filtered(lambda wh: wh.id == wh_id)
@@ -662,19 +683,19 @@ class SaleOrderMakePlan(models.TransientModel):
policy = line.product_id.product_tmpl_id.get_planning_policy()
if policy and policy.carrier_filter_id:
domain.extend(safe_eval(policy.carrier_filter_id.domain))
- carriers = self.get_shipping_carriers(base_option.get('carrier_id'), domain=domain)
+ carriers = self.get_shipping_carriers(base_option.get('carrier_id'), domain=domain, warehouse_id=base_option.get('warehouse_id'))
_logger.info('generate_shipping_options:: base_option: ' + str(base_option) + ' order_fake: ' + str(order_fake) + ' carriers: ' + str(carriers))
if not carriers:
- return base_option
+ return [base_option]
if not base_option.get('sub_options'):
options = []
# this locic comes from "delivery.models.sale_order.SaleOrder"
for carrier in carriers:
- option = self._generate_shipping_carrier_option(base_option, order_fake, carrier)
- if option:
- options.append(option)
+ carrier_options = self._generate_shipping_carrier_option(base_option, order_fake, carrier)
+ if carrier_options:
+ options += carrier_options
if options:
return options
return [base_option]
@@ -686,26 +707,40 @@ class SaleOrderMakePlan(models.TransientModel):
for carrier in carriers:
new_base_option = deepcopy(base_option)
has_error = False
+ found_carrier_ids = set()
for wh_id, wh_vals in base_option['sub_options'].items():
if has_error:
continue
order_fake.warehouse_id = warehouses.filtered(lambda wh: wh.id == wh_id)
- order_fake.order_line = FakeCollection(filter(lambda line: line.product_id.id in wh_vals['product_ids'], original_order_fake_order_line))
- wh_option = self._generate_shipping_carrier_option(wh_vals, order_fake, carrier)
- if not wh_option:
+ order_fake.order_line = FakeCollection(list(filter(lambda line: line.product_id.id in wh_vals['product_ids'], original_order_fake_order_line)))
+ wh_carrier_options = self._generate_shipping_carrier_option(wh_vals, order_fake, carrier)
+ if not wh_carrier_options:
has_error = True
else:
- new_base_option['sub_options'][wh_id] = wh_option
+ for _option in wh_carrier_options:
+ if _option.get('carrier_id'):
+ found_carrier_ids.add(_option['carrier_id'])
+ new_base_option['sub_options'][wh_id] = wh_carrier_options
if has_error:
continue
- # now that we've collected, we can roll up some details.
- new_base_option['carrier_id'] = carrier.id
- new_base_option['shipping_price'] = self._get_shipping_price_for_options(new_base_option['sub_options'])
- new_base_option['requested_date'] = self._get_max_requested_date(new_base_option['sub_options'])
- new_base_option['transit_days'] = self._get_max_transit_days(new_base_option['sub_options'])
- options.append(new_base_option)
+ # now that we've collected details for this carrier, we likely have more than one carrier's rates
+ _logger.info(' from ' + str(carrier) + ' found ' + str(found_carrier_ids))
+ for carrier_id in found_carrier_ids:
+ carrier_option = deepcopy(base_option)
+ carrier_option['carrier_id'] = False
+ for wh_id, wh_vals in base_option['sub_options'].items():
+ for co in new_base_option['sub_options'].get(wh_id, []):
+ if co.get('carrier_id') == carrier_id:
+ # we have found the rate!
+ carrier_option['carrier_id'] = carrier_id
+ carrier_option['sub_options'][wh_id] = co
+ if carrier_option['carrier_id']:
+ carrier_option['shipping_price'] = self._get_shipping_price_for_options(carrier_option['sub_options'])
+ carrier_option['requested_date'] = self._get_max_requested_date(carrier_option['sub_options'])
+ carrier_option['transit_days'] = self._get_max_transit_days(carrier_option['sub_options'])
+ options.append(carrier_option)
#restore values in case more processing occurs
order_fake.warehouse_id = original_order_fake_warehouse_id
@@ -734,6 +769,8 @@ class SaleOrderMakePlan(models.TransientModel):
def _generate_shipping_carrier_option(self, base_option, order_fake, carrier):
# some carriers look at the order carrier_id
order_fake.carrier_id = carrier
+ date_planned = base_option.get('date_planned')
+ order_fake.date_planned = date_planned
# this logic comes from "delivery.models.sale_order.SaleOrder"
try:
@@ -741,7 +778,9 @@ class SaleOrderMakePlan(models.TransientModel):
date_delivered = None
transit_days = 0
if carrier.delivery_type not in ['fixed', 'base_on_rule']:
- if hasattr(carrier, 'rate_shipment_date_planned'):
+ if hasattr(carrier, 'rate_shipment_multi'):
+ result = carrier.rate_shipment_multi(order=order_fake)
+ elif hasattr(carrier, 'rate_shipment_date_planned'):
# New API
result = carrier.rate_shipment_date_planned(order_fake, base_option.get('date_planned'))
if result:
@@ -751,7 +790,8 @@ class SaleOrderMakePlan(models.TransientModel):
elif hasattr(carrier, 'get_shipping_price_for_plan'):
# Old API
result = carrier.get_shipping_price_for_plan(order_fake, base_option.get('date_planned'))
- if result and isinstance(result, list):
+ if result and isinstance(result, list) and not isinstance(result[0], dict):
+ # this detects the above only if it isn't a list of dictionaries (aka multi-rating result)
price_unit, transit_days, date_delivered = result[0]
elif not result:
rate = carrier.rate_shipment(order_fake)
@@ -762,7 +802,7 @@ class SaleOrderMakePlan(models.TransientModel):
if rate.get('date_delivered'):
date_delivered = rate.get('date_delivered')
else:
- _logger.warning('returning None because carrier: ' + str(carrier))
+ _logger.warning('returning None because carrier: ' + str(carrier) + ' returned rate: ' + str(rate))
return None
else:
carrier = carrier.available_carriers(order_fake.partner_shipping_id)
@@ -773,13 +813,38 @@ class SaleOrderMakePlan(models.TransientModel):
if order_fake.company_id.currency_id.id != order_fake.pricelist_id.currency_id.id:
price_unit = order_fake.company_id.currency_id.with_context(date=order_fake.date_order).compute(price_unit, order_fake.pricelist_id.currency_id)
- final_price = float(price_unit) * (1.0 + (float(carrier.margin) / 100.0))
- option = deepcopy(base_option)
- option['carrier_id'] = carrier.id
- option['shipping_price'] = final_price
- option['requested_date'] = date_delivered
- option['transit_days'] = transit_days
- return option
+ if result and isinstance(result, list):
+ res = []
+ for rate in result:
+ rate_carrier = rate.get('carrier')
+ if not rate_carrier:
+ continue
+ price_unit = rate['price']
+ date_delivered = rate.get('date_delivered')
+ transit_days = rate.get('transit_days')
+
+ if date_planned and transit_days and not date_delivered:
+ # compute from planned date anc current rate carrier
+ date_delivered = rate_carrier.calculate_date_delivered(date_planned, transit_days)
+ elif date_planned and date_delivered and not transit_days:
+ transit_days = rate_carrier.calculate_transit_days(date_planned, date_delivered)
+
+ final_price = float(price_unit) * (1.0 + (float(rate_carrier.margin) / 100.0))
+ option = deepcopy(base_option)
+ option['carrier_id'] = rate_carrier.id
+ option['shipping_price'] = final_price
+ option['requested_date'] = fields.Datetime.to_string(date_delivered) if (date_delivered and isinstance(date_delivered, datetime)) else date_delivered
+ option['transit_days'] = transit_days
+ res.append(option)
+ return res
+ else:
+ final_price = float(price_unit) * (1.0 + (float(carrier.margin) / 100.0))
+ option = deepcopy(base_option)
+ option['carrier_id'] = carrier.id
+ option['shipping_price'] = final_price
+ option['requested_date'] = fields.Datetime.to_string(date_delivered) if (date_delivered and isinstance(date_delivered, datetime)) else date_delivered
+ option['transit_days'] = transit_days
+ return [option]
except Exception as e:
_logger.info("Exception collecting carrier rates: " + str(e))
# Want to see more?
@@ -796,12 +861,14 @@ class SaleOrderPlanningOption(models.TransientModel):
def datetime_converter(o):
if isinstance(o, datetime):
return str(o)
-
- if 'sub_options' in values and not isinstance(values['sub_options'], str):
- for wh_id, option in values['sub_options'].items():
- if option.get('date_planned'):
- option['date_planned'] = str(option['date_planned'])
- values['sub_options'] = dumps(values['sub_options'], default=datetime_converter)
+ if not isinstance(values, list):
+ values = [values]
+ for option_values in values:
+ if 'sub_options' in option_values and not isinstance(option_values['sub_options'], str):
+ for wh_id, option in option_values['sub_options'].items():
+ if option.get('date_planned'):
+ option['date_planned'] = str(option['date_planned'])
+ option_values['sub_options'] = dumps(option_values['sub_options'], default=datetime_converter)
return super(SaleOrderPlanningOption, self).create(values)
def _compute_sub_options_text(self):
diff --git a/stock_delivery_planner/__manifest__.py b/stock_delivery_planner/__manifest__.py
index 322249b4..e492403b 100644
--- a/stock_delivery_planner/__manifest__.py
+++ b/stock_delivery_planner/__manifest__.py
@@ -1,7 +1,7 @@
{
'name': 'Stock Delivery Planner',
'summary': 'Get rates and choose carrier for delivery.',
- 'version': '15.0.1.0.0',
+ 'version': '15.0.1.1.0',
'author': "Hibou Corp.",
'category': 'Warehouse',
'license': 'OPL-1',
diff --git a/stock_delivery_planner/models/stock.py b/stock_delivery_planner/models/stock.py
index a3d728af..5cf43780 100644
--- a/stock_delivery_planner/models/stock.py
+++ b/stock_delivery_planner/models/stock.py
@@ -47,3 +47,12 @@ class StockPicking(models.Model):
domain.extend(tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain')))
return Carrier.search(domain)
+
+
+class Warehouse(models.Model):
+ _inherit = 'stock.warehouse'
+
+ delivery_planner_carrier_ids = fields.Many2many('delivery.carrier',
+ relation='delivery_planner_carrier_wh_rel',
+ string='Delivery Planner Base Carriers',
+ help='Overrides the global carriers.')
diff --git a/stock_delivery_planner/views/stock_views.xml b/stock_delivery_planner/views/stock_views.xml
index a65334dd..137477e2 100644
--- a/stock_delivery_planner/views/stock_views.xml
+++ b/stock_delivery_planner/views/stock_views.xml
@@ -1,5 +1,6 @@
+
stock.picking.form.inherit.delivery.plannerstock.picking
@@ -10,4 +11,16 @@
+
+
+ stock.warehouse.delivery.carriers
+ stock.warehouse
+
+
+
+
+
+
+
+
diff --git a/stock_delivery_planner/wizard/stock_delivery_planner.py b/stock_delivery_planner/wizard/stock_delivery_planner.py
index e18bd3f3..67695c56 100644
--- a/stock_delivery_planner/wizard/stock_delivery_planner.py
+++ b/stock_delivery_planner/wizard/stock_delivery_planner.py
@@ -1,7 +1,7 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from odoo import api, fields, models, _
-from odoo.tools import safe_eval
+from odoo.exceptions import UserError, ValidationError
import logging
_logger = logging.getLogger(__name__)
@@ -26,32 +26,37 @@ class StockDeliveryPlanner(models.TransientModel):
def create(self, values):
planner = super(StockDeliveryPlanner, self).create(values)
- base_carriers = self.env['delivery.carrier']
- carrier_ids = self.env['ir.config_parameter'].sudo().get_param('stock.delivery.planner.carrier_ids.%s' % (self.env.company.id, ))
- if carrier_ids:
- try:
- carrier_ids = [int(c) for c in carrier_ids.split(',')]
- base_carriers = base_carriers.browse(carrier_ids)
- except:
- pass
+ base_carriers = planner.picking_id.picking_type_id.warehouse_id.delivery_planner_carrier_ids
+ if not base_carriers:
+ carrier_ids = self.env['ir.config_parameter'].sudo().get_param('stock.delivery.planner.carrier_ids.%s' % (self.env.user.company_id.id, ))
+ if carrier_ids:
+ try:
+ carrier_ids = [int(c) for c in carrier_ids.split(',')]
+ base_carriers = base_carriers.browse(carrier_ids)
+ except:
+ pass
+ base_carriers = base_carriers.sudo()
for carrier in base_carriers:
- rates = carrier.rate_shipment_multi(picking=planner.picking_id)
- for rate in filter(lambda r: not r.get('success'), rates):
- _logger.warning(rate.get('error_message'))
- for rate in filter(lambda r: r.get('success'), rates):
- rate = self.calculate_delivery_window(rate)
- # added late in API dev cycle
- package = rate.get('package') or self.env['stock.quant.package'].browse()
- planner.plan_option_ids |= planner.plan_option_ids.create({
- 'plan_id': self.id,
- 'carrier_id': rate['carrier'].id,
- 'package_id': package.id,
- 'price': rate['price'],
- 'date_planned': rate['date_planned'],
- 'requested_date': rate['date_delivered'],
- 'transit_days': rate['transit_days'],
- })
+ try:
+ rates = carrier.rate_shipment_multi(picking=planner.picking_id)
+ for rate in filter(lambda r: not r.get('success'), rates):
+ _logger.warning(rate.get('error_message'))
+ for rate in filter(lambda r: r.get('success'), rates):
+ rate = self.calculate_delivery_window(rate)
+ # added late in API dev cycle
+ package = rate.get('package') or self.env['stock.quant.package'].browse()
+ planner.plan_option_ids |= planner.plan_option_ids.create({
+ 'plan_id': self.id,
+ 'carrier_id': rate['carrier'].id,
+ 'package_id': package.id,
+ 'price': rate['price'],
+ 'date_planned': rate['date_planned'],
+ 'requested_date': rate.get('date_delivered', False),
+ 'transit_days': rate.get('transit_days', 0),
+ })
+ except (UserError, ValidationError) as e:
+ _logger.warning('Exception during delivery planning. %s' % str(e))
return planner
@api.model
diff --git a/stock_delivery_planner/wizard/stock_delivery_planner_views.xml b/stock_delivery_planner/wizard/stock_delivery_planner_views.xml
index ac373043..06e9ac5c 100644
--- a/stock_delivery_planner/wizard/stock_delivery_planner_views.xml
+++ b/stock_delivery_planner/wizard/stock_delivery_planner_views.xml
@@ -11,6 +11,7 @@