Merge branch 'imp/15.0/delivery_hibou__sig_req' into '15.0'

WIP: imp/15.0/delivery_hibou__sig_req into 15.0

See merge request hibou-io/hibou-odoo/suite!1357
This commit is contained in:
Jared Kipe
2022-02-10 21:38:40 +00:00
35 changed files with 1535 additions and 103 deletions

View File

@@ -0,0 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import models

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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<br/>"
"<b>Tracking Numbers:</b> %s<br/>"
"<b>Packages:</b> %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 <br/> <b>Tracking Number : </b>%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'])

View File

@@ -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

View File

@@ -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')

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="fedex_view_warehouse" model="ir.ui.view">
<field name="name">stock.warehouse</field>
<field name="model">stock.warehouse</field>
<field name="inherit_id" ref="stock.view_warehouse" />
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="fedex_account_number"/>
<field name="fedex_meter_number"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,7 +1,7 @@
{ {
'name': 'Golden State Overnight (gso.com) Shipping', 'name': 'Golden State Overnight (gso.com) Shipping',
'summary': 'Send your shippings through gso.com and track them online.', '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.", 'author': "Hibou Corp.",
'category': 'Warehouse', 'category': 'Warehouse',
'license': 'OPL-1', 'license': 'OPL-1',

View File

@@ -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_shipper_address(from_, company))
request_body['Shipment'].update(self._gso_make_ship_address(to)) 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 cost = 0.0
labels = { labels = {
'thermal': [], 'thermal': [],
@@ -218,6 +210,20 @@ class ProviderGSO(models.Model):
if picking_packages: if picking_packages:
# Every package will be a transaction # Every package will be a transaction
for package in picking_packages: 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']['Weight'] = self._gso_convert_weight(package.shipping_weight)
request_body['Shipment'].update(self._gso_get_package_dimensions(package)) request_body['Shipment'].update(self._gso_get_package_dimensions(package))
request_body['Shipment']['ShipmentReference'] = package.name request_body['Shipment']['ShipmentReference'] = package.name
@@ -236,9 +242,10 @@ class ProviderGSO(models.Model):
raise ValidationError(e) raise ValidationError(e)
elif not package_carriers: elif not package_carriers:
# ship the whole picking # 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']['Weight'] = self._gso_convert_weight(picking.shipping_weight)
request_body['Shipment'].update(self._gso_get_package_dimensions()) 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) request_body['Shipment']['TrackingNumber'] = self._gso_create_tracking_number(picking.name)
try: try:
response = service.post_shipment(request_body) response = service.post_shipment(request_body)
@@ -393,7 +400,7 @@ class ProviderGSO(models.Model):
elif not package: elif not package:
est_weight_value = self._gso_convert_weight(picking.shipping_weight) est_weight_value = self._gso_convert_weight(picking.shipping_weight)
else: 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 = { request_body = {
'AccountNumber': sudoself.gso_account_number, 'AccountNumber': sudoself.gso_account_number,
@@ -409,7 +416,7 @@ class ProviderGSO(models.Model):
result = service.get_rates_and_transit_time(request_body) result = service.get_rates_and_transit_time(request_body)
# _logger.warning('GSO result:\n%s' % result) # _logger.warning('GSO result:\n%s' % result)
except HTTPError as e: except HTTPError as e:
_logger.error(e) # _logger.error(e)
return [{ return [{
'success': False, 'success': False,
'price': 0.0, 'price': 0.0,

View File

@@ -1,7 +1,7 @@
{ {
'name': 'Delivery Hibou', 'name': 'Delivery Hibou',
'summary': 'Adds underlying pinnings for things like "RMA Return Labels"', '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.", 'author': "Hibou Corp.",
'category': 'Stock', 'category': 'Stock',
'license': 'LGPL-3', 'license': 'LGPL-3',

View File

@@ -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.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -9,6 +10,9 @@ class DeliveryCarrier(models.Model):
automatic_insurance_value = fields.Float(string='Automatic Insurance Value', automatic_insurance_value = fields.Float(string='Automatic Insurance Value',
help='Will be used during shipping to determine if the ' help='Will be used during shipping to determine if the '
'picking\'s value warrants insurance being added.') '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, procurement_priority = fields.Selection(PROCUREMENT_PRIORITIES,
string='Procurement Priority', string='Procurement Priority',
help='Priority for this carrier. Will affect pickings ' help='Priority for this carrier. Will affect pickings '
@@ -16,21 +20,42 @@ class DeliveryCarrier(models.Model):
# Utility # Utility
def get_insurance_value(self, order=None, picking=None): def get_insurance_value(self, order=None, picking=None, package=None):
value = 0.0 value = 0.0
if order: if order:
if order.order_line: 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: else:
return value return value
if picking: if picking:
value = picking.declared_value() value = picking.declared_value(package=package)
if picking.require_insurance == 'no': if package and not package.require_insurance:
value = 0.0
elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value:
value = 0.0 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 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): def get_third_party_account(self, order=None, picking=None):
if order and order.shipping_account_id: if order and order.shipping_account_id:
return order.shipping_account_id return order.shipping_account_id
@@ -202,9 +227,9 @@ class DeliveryCarrier(models.Model):
res = [] res = []
for carrier in self: for carrier in self:
carrier_packages = packages.filtered(lambda p: not p.carrier_tracking_ref and carrier_packages = packages and packages.filtered(lambda p: not p.carrier_tracking_ref and
(not p.carrier_id or p.carrier_id == carrier) and (not p.carrier_id or p.carrier_id == carrier) and
p.package_type_id.package_carrier_type in (False, '', 'none', carrier.delivery_type)) p.package_type_id.package_carrier_type in (False, '', 'none', carrier.delivery_type))
if packages and not carrier_packages: if packages and not carrier_packages:
continue continue
if hasattr(carrier, '%s_rate_shipment_multi' % self.delivery_type): 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) 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,
})

View File

@@ -7,6 +7,9 @@ class StockQuantPackage(models.Model):
carrier_id = fields.Many2one('delivery.carrier', string='Carrier') carrier_id = fields.Many2one('delivery.carrier', string='Carrier')
carrier_tracking_ref = fields.Char(string='Tracking Reference') 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): def _get_active_picking(self):
picking_id = self._context.get('active_id') picking_id = self._context.get('active_id')
@@ -34,7 +37,14 @@ class StockPicking(models.Model):
('no', 'No'), ('no', 'No'),
], string='Require Insurance', default='auto', ], string='Require Insurance', default='auto',
help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.') 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') 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') @api.depends('package_ids.carrier_tracking_ref')
def _compute_package_carrier_tracking_ref(self): def _compute_package_carrier_tracking_ref(self):
@@ -67,8 +77,10 @@ class StockPicking(models.Model):
res = super(StockPicking, self).create(values) res = super(StockPicking, self).create(values)
return res return res
def declared_value(self): def declared_value(self, package=None):
self.ensure_one() 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]) cost = sum([(l.product_id.standard_price * l.qty_done) for l in self.move_line_ids] or [0.0])
if not cost: if not cost:
# Assume Full Value # Assume Full Value
@@ -112,6 +124,8 @@ class StockPicking(models.Model):
tracking_numbers.append(tracking_number) tracking_numbers.append(tracking_number)
# Try to add tracking to the individual packages. # Try to add tracking to the individual packages.
potential_tracking_numbers = tracking_number.split(',') 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): if len(potential_tracking_numbers) >= len(carrier_packages):
for t, p in zip(potential_tracking_numbers, carrier_packages): for t, p in zip(potential_tracking_numbers, carrier_packages):
p.carrier_tracking_ref = t p.carrier_tracking_ref = t
@@ -150,9 +164,10 @@ class StockPicking(models.Model):
for carrier in carriers: for carrier in carriers:
carrier_packages = packages_with_carrier.filtered(lambda p: p.carrier_id == carrier) carrier_packages = packages_with_carrier.filtered(lambda p: p.carrier_id == carrier)
carrier.cancel_shipment(self, packages=carrier_packages) carrier.cancel_shipment(self, packages=carrier_packages)
package_refs = ','.join(carrier_packages.mapped('carrier_tracking_ref')) # Above cancel should also say which are cancelled in chatter.
msg = "Shipment %s cancelled" % package_refs # package_refs = ','.join(carrier_packages.mapped('carrier_tracking_ref'))
picking.message_post(body=msg) # msg = "Shipment %s cancelled" % package_refs
# picking.message_post(body=msg)
carrier_packages.write({'carrier_tracking_ref': False}) carrier_packages.write({'carrier_tracking_ref': False})
pickings_without_package_tracking = self - pickings_with_package_tracking pickings_without_package_tracking = self - pickings_with_package_tracking

View File

@@ -7,6 +7,7 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='integration_level']" position="after"> <xpath expr="//field[@name='integration_level']" position="after">
<field name="automatic_insurance_value"/> <field name="automatic_insurance_value"/>
<field name="automatic_sig_req_value"/>
<field name="procurement_priority"/> <field name="procurement_priority"/>
</xpath> </xpath>
</field> </field>
@@ -20,6 +21,11 @@
<xpath expr="//field[@name='delivery_package_type_id']" position="attributes"> <xpath expr="//field[@name='delivery_package_type_id']" position="attributes">
<attribute name="domain">[]</attribute> <attribute name="domain">[]</attribute>
</xpath> </xpath>
<xpath expr="//field[@name='delivery_package_type_id']" position="after">
<field name="package_declared_value" />
<field name="package_require_insurance" />
<field name="package_require_signature" />
</xpath>
</field> </field>
</record> </record>

View File

@@ -17,6 +17,9 @@
<field name="carrier_tracking_ref" class="oe_inline" /> <field name="carrier_tracking_ref" class="oe_inline" />
<button type="object" class="fa fa-arrow-right oe_link" name="cancel_shipment" string="Cancel" attrs="{'invisible':['|',('carrier_tracking_ref','=',False),('carrier_id','=', False)]}"/> <button type="object" class="fa fa-arrow-right oe_link" name="cancel_shipment" string="Cancel" attrs="{'invisible':['|',('carrier_tracking_ref','=',False),('carrier_id','=', False)]}"/>
</div> </div>
<field name="declared_value" />
<field name="require_insurance" />
<field name="require_signature" />
</xpath> </xpath>
</field> </field>
</record> </record>
@@ -28,8 +31,12 @@
<field name="priority" eval="200" /> <field name="priority" eval="200" />
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='carrier_id']" position="before"> <xpath expr="//field[@name='carrier_id']" position="before">
<field name="commercial_partner_id" invisible="1" />
<field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/> <field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
<field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/> <field name="require_signature" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
<field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"
options="{'no_create': True, 'no_open': True}"
domain="[('partner_id', 'in', (False, partner_id, commercial_partner_id))]"/>
<field name="package_carrier_tracking_ref" attrs="{'invisible': [('package_carrier_tracking_ref', '=', False)]}" /> <field name="package_carrier_tracking_ref" attrs="{'invisible': [('package_carrier_tracking_ref', '=', False)]}" />
<button name="clear_carrier_tracking_ref" type="object" string="Clear Tracking" attrs="{'invisible': [('carrier_tracking_ref', '!=', False)]}" /> <button name="clear_carrier_tracking_ref" type="object" string="Clear Tracking" attrs="{'invisible': [('carrier_tracking_ref', '!=', False)]}" />
<button name="reset_carrier_tracking_ref" type="object" string="Reset Tracking" attrs="{'invisible': [('package_carrier_tracking_ref', '!=', False)]}" /> <button name="reset_carrier_tracking_ref" type="object" string="Reset Tracking" attrs="{'invisible': [('package_carrier_tracking_ref', '!=', False)]}" />

View File

@@ -1,2 +1,4 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import wizard from . import wizard
from . import models from . import models

View File

@@ -1,10 +1,10 @@
{ {
'name': 'Sale Order Planner', 'name': 'Sale Order Planner',
'summary': 'Plans order dates and warehouses.', 'summary': 'Plans order dates and warehouses.',
'version': '15.0.1.0.0', 'version': '15.0.2.0.0',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'category': 'Sale', 'category': 'Sale',
'license': 'AGPL-3', 'license': 'OPL-1',
'complexity': 'expert', 'complexity': 'expert',
'images': [], 'images': [],
'website': "https://hibou.io", '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/stock.xml',
'views/delivery.xml', 'views/delivery.xml',
'views/product.xml', 'views/product.xml',
'views/res_config_settings_views.xml',
], ],
'auto_install': False, 'auto_install': False,
'installable': True, 'installable': True,

View File

@@ -1,3 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import delivery from . import delivery
from . import partner from . import partner
from . import planning from . import planning
@@ -5,3 +7,4 @@ from . import product
from . import resource from . import resource
from . import sale from . import sale
from . import stock from . import stock
from . import res_config_settings

View File

@@ -1,3 +1,5 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from datetime import timedelta from datetime import timedelta
from odoo import api, fields, models from odoo import api, fields, models
@@ -61,6 +63,9 @@ class DeliveryCarrier(models.Model):
def calculate_transit_days(self, date_planned, date_delivered): def calculate_transit_days(self, date_planned, date_delivered):
self.ensure_one() self.ensure_one()
if not self.delivery_calendar_id:
return 0
if isinstance(date_planned, str): if isinstance(date_planned, str):
date_planned = fields.Datetime.from_string(date_planned) date_planned = fields.Datetime.from_string(date_planned)
if isinstance(date_delivered, str): if isinstance(date_delivered, str):

View File

@@ -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 from odoo import api, fields, models
try: try:

View File

@@ -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 from odoo import api, fields, models

View File

@@ -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 from odoo import api, fields, models

View File

@@ -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

View File

@@ -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 from odoo import api, fields, models

View File

@@ -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 from odoo import api, fields, models
@@ -7,3 +9,7 @@ class Warehouse(models.Model):
shipping_calendar_id = fields.Many2one( shipping_calendar_id = fields.Many2one(
'resource.calendar', 'Shipping Calendar', 'resource.calendar', 'Shipping Calendar',
help="This calendar represents shipping availability from the warehouse.") 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.')

View File

@@ -1 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import test_planner from . import test_planner

View File

@@ -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 odoo.tests import common
from datetime import datetime, timedelta from datetime import datetime, timedelta
from json import loads as json_decode from json import loads as json_decode

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="49"/>
<field name="inherit_id" ref="delivery.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@data-key='sale_management']" position="inside">
<h2>Sale Order Planner</h2>
<div class="col-lg-6 col-12 o_setting_box" id="sale_planner_carriers">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="sale_planner_carrier_ids" />
<div class="text-muted">
Add a carrier that represents the 'base rate' for a carrier's type. <br/>
For example, you should add 1 FedEx carrier here and let us build up the
rates for your other FedEx shipping methods.
</div>
<field name="sale_planner_carrier_ids" class="oe_inline" options="{'no_create_edit': True, 'no_create': True}" />
</div>
</div>
<div class="col-lg-6 col-12 o_setting_box" id="sale_planner_warehouses">
<div class="o_setting_left_pane"/>
<div class="o_setting_right_pane">
<label for="sale_planner_warehouse_ids" />
<div class="text-muted">
Warehouses you typically ship inventory out of that you want to
include in the planning of sale orders.
</div>
<field name="sale_planner_warehouse_ids" class="oe_inline" options="{'no_create_edit': True, 'no_create': True}" />
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View File

@@ -7,6 +7,7 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after"> <xpath expr="//field[@name='partner_id']" position="after">
<field name="shipping_calendar_id" /> <field name="shipping_calendar_id" />
<field name="sale_planner_carrier_ids" options="{'no_create_edit': True, 'no_create': True}" />
</xpath> </xpath>
</field> </field>
</record> </record>

View File

@@ -1 +1,3 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from . import order_planner from . import order_planner

View File

@@ -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 math import sin, cos, sqrt, atan2, radians
from json import dumps, loads from json import dumps, loads
from copy import deepcopy from copy import deepcopy
@@ -10,11 +12,12 @@ _logger = getLogger(__name__)
try: try:
from uszipcode import SearchEngine from uszipcode import SearchEngine
except ImportError: 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 SearchEngine = None
from odoo import api, fields, models from odoo import api, fields, models
from odoo.tools.safe_eval import safe_eval from odoo.tools.safe_eval import safe_eval
from ..models.res_config_settings import sale_planner_warehouse_ids, sale_planner_carrier_ids
class FakeCollection(): class FakeCollection():
@@ -26,7 +29,12 @@ class FakeCollection():
yield v yield v
def filtered(self, f): 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): def sudo(self, *args, **kwargs):
return self return self
@@ -179,6 +187,12 @@ class FakeSaleOrder(FakeCollection):
return str(datetime.now()) return str(datetime.now())
return getattr(self, item) 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): def distance(lat_1, lon_1, lat_2, lon_2):
R = 6373.0 R = 6373.0
@@ -308,15 +322,17 @@ class SaleOrderMakePlan(models.TransientModel):
domain.append(('company_id', 'in', self.env.context['allowed_company_ids'])) domain.append(('company_id', 'in', self.env.context['allowed_company_ids']))
if self.env.context.get('warehouse_domain'): if self.env.context.get('warehouse_domain'):
if not domain:
domain = []
domain.extend(self.env.context.get('warehouse_domain')) domain.extend(self.env.context.get('warehouse_domain'))
if domain:
return warehouse.search(domain)
irconfig_parameter = self.env['ir.config_parameter'].sudo() # no domain, use global
if irconfig_parameter.get_param('sale.order.planner.warehouse_domain'): warehouse_ids = sale_planner_warehouse_ids(self.env, self.env.user.company_id)
domain.extend(safe_eval(irconfig_parameter.get_param('sale.order.planner.warehouse_domain'))) return warehouse.browse(warehouse_ids)
return warehouse.search(domain) def get_shipping_carriers(self, carrier_id=None, domain=None, warehouse_id=None):
def get_shipping_carriers(self, carrier_id=None, domain=None):
Carrier = self.env['delivery.carrier'].sudo() Carrier = self.env['delivery.carrier'].sudo()
if carrier_id: if carrier_id:
return Carrier.browse(carrier_id) return Carrier.browse(carrier_id)
@@ -324,18 +340,21 @@ class SaleOrderMakePlan(models.TransientModel):
if domain: if domain:
if not isinstance(domain, (list, tuple)): if not isinstance(domain, (list, tuple)):
domain = safe_eval(domain) domain = safe_eval(domain)
else:
domain = []
if self.env.context.get('carrier_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')) domain.extend(self.env.context.get('carrier_domain'))
if domain:
return Carrier.search(domain)
irconfig_parameter = self.env['ir.config_parameter'].sudo() # no domain, use global
if irconfig_parameter.get_param('sale.order.planner.carrier_domain'): if warehouse_id:
domain.extend(safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain'))) warehouse = self.env['stock.warehouse'].sudo().browse(warehouse_id)
if warehouse.sale_planner_carrier_ids:
return Carrier.search(domain) 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): def _generate_base_option(self, order_fake, policy_group):
flag_force_closest = False flag_force_closest = False
@@ -616,6 +635,8 @@ class SaleOrderMakePlan(models.TransientModel):
return self._find_closest_warehouse(warehouses, partner.partner_latitude, partner.partner_longitude) return self._find_closest_warehouse(warehouses, partner.partner_latitude, partner.partner_longitude)
def _find_closest_warehouse(self, warehouses, latitude, 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} 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)] wh_id = distances[min(distances)]
return warehouses.filtered(lambda wh: wh.id == wh_id) 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() policy = line.product_id.product_tmpl_id.get_planning_policy()
if policy and policy.carrier_filter_id: if policy and policy.carrier_filter_id:
domain.extend(safe_eval(policy.carrier_filter_id.domain)) 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)) _logger.info('generate_shipping_options:: base_option: ' + str(base_option) + ' order_fake: ' + str(order_fake) + ' carriers: ' + str(carriers))
if not carriers: if not carriers:
return base_option return [base_option]
if not base_option.get('sub_options'): if not base_option.get('sub_options'):
options = [] options = []
# this locic comes from "delivery.models.sale_order.SaleOrder" # this locic comes from "delivery.models.sale_order.SaleOrder"
for carrier in carriers: for carrier in carriers:
option = self._generate_shipping_carrier_option(base_option, order_fake, carrier) carrier_options = self._generate_shipping_carrier_option(base_option, order_fake, carrier)
if option: if carrier_options:
options.append(option) options += carrier_options
if options: if options:
return options return options
return [base_option] return [base_option]
@@ -686,26 +707,40 @@ class SaleOrderMakePlan(models.TransientModel):
for carrier in carriers: for carrier in carriers:
new_base_option = deepcopy(base_option) new_base_option = deepcopy(base_option)
has_error = False has_error = False
found_carrier_ids = set()
for wh_id, wh_vals in base_option['sub_options'].items(): for wh_id, wh_vals in base_option['sub_options'].items():
if has_error: if has_error:
continue continue
order_fake.warehouse_id = warehouses.filtered(lambda wh: wh.id == wh_id) 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)) order_fake.order_line = FakeCollection(list(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) wh_carrier_options = self._generate_shipping_carrier_option(wh_vals, order_fake, carrier)
if not wh_option: if not wh_carrier_options:
has_error = True has_error = True
else: 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: if has_error:
continue continue
# now that we've collected, we can roll up some details.
new_base_option['carrier_id'] = carrier.id # now that we've collected details for this carrier, we likely have more than one carrier's rates
new_base_option['shipping_price'] = self._get_shipping_price_for_options(new_base_option['sub_options']) _logger.info(' from ' + str(carrier) + ' found ' + str(found_carrier_ids))
new_base_option['requested_date'] = self._get_max_requested_date(new_base_option['sub_options']) for carrier_id in found_carrier_ids:
new_base_option['transit_days'] = self._get_max_transit_days(new_base_option['sub_options']) carrier_option = deepcopy(base_option)
options.append(new_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 #restore values in case more processing occurs
order_fake.warehouse_id = original_order_fake_warehouse_id 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): def _generate_shipping_carrier_option(self, base_option, order_fake, carrier):
# some carriers look at the order carrier_id # some carriers look at the order carrier_id
order_fake.carrier_id = carrier 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" # this logic comes from "delivery.models.sale_order.SaleOrder"
try: try:
@@ -741,7 +778,9 @@ class SaleOrderMakePlan(models.TransientModel):
date_delivered = None date_delivered = None
transit_days = 0 transit_days = 0
if carrier.delivery_type not in ['fixed', 'base_on_rule']: 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 # New API
result = carrier.rate_shipment_date_planned(order_fake, base_option.get('date_planned')) result = carrier.rate_shipment_date_planned(order_fake, base_option.get('date_planned'))
if result: if result:
@@ -751,7 +790,8 @@ class SaleOrderMakePlan(models.TransientModel):
elif hasattr(carrier, 'get_shipping_price_for_plan'): elif hasattr(carrier, 'get_shipping_price_for_plan'):
# Old API # Old API
result = carrier.get_shipping_price_for_plan(order_fake, base_option.get('date_planned')) 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] price_unit, transit_days, date_delivered = result[0]
elif not result: elif not result:
rate = carrier.rate_shipment(order_fake) rate = carrier.rate_shipment(order_fake)
@@ -762,7 +802,7 @@ class SaleOrderMakePlan(models.TransientModel):
if rate.get('date_delivered'): if rate.get('date_delivered'):
date_delivered = rate.get('date_delivered') date_delivered = rate.get('date_delivered')
else: else:
_logger.warning('returning None because carrier: ' + str(carrier)) _logger.warning('returning None because carrier: ' + str(carrier) + ' returned rate: ' + str(rate))
return None return None
else: else:
carrier = carrier.available_carriers(order_fake.partner_shipping_id) 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: 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) 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)) if result and isinstance(result, list):
option = deepcopy(base_option) res = []
option['carrier_id'] = carrier.id for rate in result:
option['shipping_price'] = final_price rate_carrier = rate.get('carrier')
option['requested_date'] = date_delivered if not rate_carrier:
option['transit_days'] = transit_days continue
return option 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: except Exception as e:
_logger.info("Exception collecting carrier rates: " + str(e)) _logger.info("Exception collecting carrier rates: " + str(e))
# Want to see more? # Want to see more?
@@ -796,12 +861,14 @@ class SaleOrderPlanningOption(models.TransientModel):
def datetime_converter(o): def datetime_converter(o):
if isinstance(o, datetime): if isinstance(o, datetime):
return str(o) return str(o)
if not isinstance(values, list):
if 'sub_options' in values and not isinstance(values['sub_options'], str): values = [values]
for wh_id, option in values['sub_options'].items(): for option_values in values:
if option.get('date_planned'): if 'sub_options' in option_values and not isinstance(option_values['sub_options'], str):
option['date_planned'] = str(option['date_planned']) for wh_id, option in option_values['sub_options'].items():
values['sub_options'] = dumps(values['sub_options'], default=datetime_converter) 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) return super(SaleOrderPlanningOption, self).create(values)
def _compute_sub_options_text(self): def _compute_sub_options_text(self):

View File

@@ -1,7 +1,7 @@
{ {
'name': 'Stock Delivery Planner', 'name': 'Stock Delivery Planner',
'summary': 'Get rates and choose carrier for delivery.', 'summary': 'Get rates and choose carrier for delivery.',
'version': '15.0.1.0.0', 'version': '15.0.1.1.0',
'author': "Hibou Corp.", 'author': "Hibou Corp.",
'category': 'Warehouse', 'category': 'Warehouse',
'license': 'OPL-1', 'license': 'OPL-1',

View File

@@ -47,3 +47,12 @@ class StockPicking(models.Model):
domain.extend(tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain'))) domain.extend(tools.safe_eval(irconfig_parameter.get_param('sale.order.planner.carrier_domain')))
return Carrier.search(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.')

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<record id="view_picking_form" model="ir.ui.view"> <record id="view_picking_form" model="ir.ui.view">
<field name="name">stock.picking.form.inherit.delivery.planner</field> <field name="name">stock.picking.form.inherit.delivery.planner</field>
<field name="model">stock.picking</field> <field name="model">stock.picking</field>
@@ -10,4 +11,16 @@
</xpath> </xpath>
</field> </field>
</record> </record>
<record id="view_warehouse_delivery_carriers" model="ir.ui.view">
<field name="name">stock.warehouse.delivery.carriers</field>
<field name="model">stock.warehouse</field>
<field name="inherit_id" ref="stock.view_warehouse" />
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="delivery_planner_carrier_ids" options="{'no_create_edit': True, 'no_create': True}" />
</xpath>
</field>
</record>
</odoo> </odoo>

View File

@@ -1,7 +1,7 @@
# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. # Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.tools import safe_eval from odoo.exceptions import UserError, ValidationError
import logging import logging
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -26,32 +26,37 @@ class StockDeliveryPlanner(models.TransientModel):
def create(self, values): def create(self, values):
planner = super(StockDeliveryPlanner, self).create(values) planner = super(StockDeliveryPlanner, self).create(values)
base_carriers = self.env['delivery.carrier'] base_carriers = planner.picking_id.picking_type_id.warehouse_id.delivery_planner_carrier_ids
carrier_ids = self.env['ir.config_parameter'].sudo().get_param('stock.delivery.planner.carrier_ids.%s' % (self.env.company.id, )) if not base_carriers:
if carrier_ids: carrier_ids = self.env['ir.config_parameter'].sudo().get_param('stock.delivery.planner.carrier_ids.%s' % (self.env.user.company_id.id, ))
try: if carrier_ids:
carrier_ids = [int(c) for c in carrier_ids.split(',')] try:
base_carriers = base_carriers.browse(carrier_ids) carrier_ids = [int(c) for c in carrier_ids.split(',')]
except: base_carriers = base_carriers.browse(carrier_ids)
pass except:
pass
base_carriers = base_carriers.sudo()
for carrier in base_carriers: for carrier in base_carriers:
rates = carrier.rate_shipment_multi(picking=planner.picking_id) try:
for rate in filter(lambda r: not r.get('success'), rates): rates = carrier.rate_shipment_multi(picking=planner.picking_id)
_logger.warning(rate.get('error_message')) for rate in filter(lambda r: not r.get('success'), rates):
for rate in filter(lambda r: r.get('success'), rates): _logger.warning(rate.get('error_message'))
rate = self.calculate_delivery_window(rate) for rate in filter(lambda r: r.get('success'), rates):
# added late in API dev cycle rate = self.calculate_delivery_window(rate)
package = rate.get('package') or self.env['stock.quant.package'].browse() # added late in API dev cycle
planner.plan_option_ids |= planner.plan_option_ids.create({ package = rate.get('package') or self.env['stock.quant.package'].browse()
'plan_id': self.id, planner.plan_option_ids |= planner.plan_option_ids.create({
'carrier_id': rate['carrier'].id, 'plan_id': self.id,
'package_id': package.id, 'carrier_id': rate['carrier'].id,
'price': rate['price'], 'package_id': package.id,
'date_planned': rate['date_planned'], 'price': rate['price'],
'requested_date': rate['date_delivered'], 'date_planned': rate['date_planned'],
'transit_days': rate['transit_days'], '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 return planner
@api.model @api.model

View File

@@ -11,6 +11,7 @@
<field name="plan_option_ids" nolabel="1"> <field name="plan_option_ids" nolabel="1">
<tree decoration-info="selection == 'selected'" <tree decoration-info="selection == 'selected'"
decoration-muted="selection == 'deselected'" decoration-muted="selection == 'deselected'"
decoration-bf="days_different == 0.0"
default_order="package_id, price" default_order="package_id, price"
create="false" edit="false" delete="false"> create="false" edit="false" delete="false">
<field name="package_id" /> <field name="package_id" />