476 lines
25 KiB
Python
476 lines
25 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from time import sleep
|
|
import logging
|
|
|
|
from odoo import _, fields, models
|
|
from odoo.exceptions import UserError, ValidationError
|
|
|
|
from .starshipit_service import Starshipit
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
CARRIER_SUPPORTING_RETURNS = [
|
|
'AusPost',
|
|
'TNT',
|
|
'CouriersPlease',
|
|
'Fastways',
|
|
'StarTrack',
|
|
'DHL',
|
|
'NzPost',
|
|
'PlainLabel'
|
|
]
|
|
|
|
|
|
class DeliveryCarrier(models.Model):
|
|
_inherit = 'delivery.carrier'
|
|
|
|
delivery_type = fields.Selection(selection_add=[
|
|
('starshipit', 'Starshipit'),
|
|
], ondelete={'starshipit': lambda records: records.write({'delivery_type': 'fixed', 'fixed_price': 0})})
|
|
|
|
starshipit_api_key = fields.Char(help='Starshipit API Integration key')
|
|
starshipit_subscription_key = fields.Char(help='Starshipit API Subscription key')
|
|
starshipit_default_package_type_id = fields.Many2one(
|
|
'stock.package.type',
|
|
string='Default Package Type for Starshipit',
|
|
help='Package dimensions are required to get more accurate rates. You can define these in a package type that you set as default',
|
|
)
|
|
starshipit_origin_address = fields.Many2one(
|
|
string="Origin Address",
|
|
help="This address will be used when fetching the available services from starshipit.",
|
|
comodel_name='res.partner',
|
|
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
|
|
default=lambda self: self.env.company.partner_id,
|
|
)
|
|
starshipit_carrier_code = fields.Char(
|
|
string='Carrier Code',
|
|
help='The carrier on starshipit used by this carrier. The service code belongs to it.',
|
|
export_string_translation=False,
|
|
readonly=True,
|
|
)
|
|
starshipit_service_code = fields.Char(
|
|
string='Service Code',
|
|
help='The service that will be used for this carrier. This is set when you select a carrier from the wizard.',
|
|
readonly=True,
|
|
)
|
|
starshipit_service_name = fields.Char(
|
|
string='Service Name',
|
|
help='The service that will be used for this carrier. This is set when you select a carrier from the wizard.',
|
|
readonly=True,
|
|
)
|
|
|
|
def _compute_can_generate_return(self):
|
|
""" Starshipit only supports returns for some carriers. """
|
|
super()._compute_can_generate_return()
|
|
for carrier in self:
|
|
if carrier.delivery_type == 'starshipit':
|
|
carrier.can_generate_return = carrier.starshipit_carrier_code in CARRIER_SUPPORTING_RETURNS
|
|
|
|
# Shipping Carrier Methods
|
|
|
|
def starshipit_rate_shipment(self, order):
|
|
""" Get the rates for the given order, according to the selected service code for this carrier.
|
|
This method is used when getting the rate for a specific shipping.method.
|
|
"""
|
|
starshipit = self._get_starshipit()
|
|
|
|
rates = starshipit._rate_shipment(
|
|
self._starshipit_get_package_information(order=order)[0],
|
|
order=order,
|
|
)
|
|
rate = rates['success'] and rates['rates'].get(self.starshipit_service_code)
|
|
if rate:
|
|
return {
|
|
'success': True,
|
|
'price': rate['total_price'],
|
|
'error_message': False,
|
|
'warning_message': False,
|
|
}
|
|
else:
|
|
return {
|
|
'success': False,
|
|
'price': 0.0,
|
|
'error_message': _('Error: this delivery method is not available for this order.'),
|
|
'warning_message': False,
|
|
}
|
|
|
|
def starshipit_send_shipping(self, pickings, is_return=False):
|
|
""" For a given picking, this method will execute a few API calls in order to get the order to be sent to the carrier.
|
|
The order of actions is:
|
|
|
|
- Create the order(s) on starshipit side. This will not send them, just register them and return the id(s)
|
|
- Get the labels for each picking, one at a time. This will return the tracking number(s) and url(s).
|
|
- The labels are attached to the picking as ir.attachment
|
|
- If return_label_on_delivery is set, generate the return label(s) for each picking too.
|
|
- Get the delivery order information from starshipit to fetch the final rate, and whether the order was manifested or not.
|
|
- Finally either manifest (send) the order(s) or archive them in test mode.
|
|
If sent, the manifest report is added to the picking as ir.attachment.
|
|
"""
|
|
starshipit = self._get_starshipit()
|
|
unshipped_orders = starshipit._create_orders(self, pickings, is_return)['orders']
|
|
res = []
|
|
|
|
for picking in pickings:
|
|
starshipit_order_number = starshipit._get_starshipit_order_number(picking)
|
|
# We cant use the api that prints labels for multiple order at once because this endpoint doesn't return the tracking information.
|
|
order = unshipped_orders.get(starshipit_order_number)
|
|
order_id = order['order_id']
|
|
picking.starshipit_parcel_reference = order_id
|
|
|
|
label_data = self._create_label_for_order(order_id, starshipit_order_number)
|
|
|
|
tracking_number = ', '.join(tracking_number for tracking_number in label_data['tracking_numbers'] if tracking_number is not None)
|
|
picking.carrier_tracking_ref = tracking_number
|
|
try:
|
|
# generate return if config is set
|
|
if self.return_label_on_delivery:
|
|
self.get_return_label(picking)
|
|
except UserError:
|
|
# if the return fails need to log that they failed and continue
|
|
picking.message_post(body=_('The return label creation failed.'))
|
|
|
|
# Get the exact price for the shipping.
|
|
attachment_data = []
|
|
# Attach the labels we got to the picking
|
|
for label in label_data['labels']:
|
|
attachment_data.append({
|
|
'name': f'{self._get_delivery_label_prefix()}-{picking.name.replace("/", "_").lower()}.pdf',
|
|
'datas': label,
|
|
'type': 'binary',
|
|
'res_model': picking._name,
|
|
'res_id': picking.id,
|
|
})
|
|
attachment_ids = self.env['ir.attachment'].create(attachment_data)
|
|
|
|
order_data = starshipit._get_order_details(order_id)
|
|
total_shipping_price = order_data['order'].get('total_shipping_price', 0.0)
|
|
|
|
if not total_shipping_price:
|
|
picking.message_post(body=_('The exact price could not be retrieved. It will be updated by a scheduled action.'))
|
|
|
|
order_result = {
|
|
'exact_price': total_shipping_price,
|
|
'tracking_number': tracking_number,
|
|
}
|
|
manifested = order_data['order']['manifested']
|
|
res.append(order_result)
|
|
if attachment_ids:
|
|
picking.message_post(body=_('Labels were generated for the order %s', picking.name), attachment_ids=attachment_ids.ids)
|
|
# In production, we can manifest the order, and it will be sent to the carrier.
|
|
if self.prod_environment:
|
|
if not manifested:
|
|
result = starshipit._manifest_orders([picking.starshipit_parcel_reference])
|
|
# Starshipit API doesn't return a PDF for TNT service
|
|
datas = result.get('pdf')
|
|
pdf_name = f'{self._get_delivery_doc_prefix()}-manifest-report-{picking.name.replace("/", "_").lower()}.pdf'
|
|
if datas:
|
|
attachment_id = self.env['ir.attachment'].create({
|
|
'name': pdf_name,
|
|
'datas': datas,
|
|
'type': 'binary',
|
|
'res_model': picking._name,
|
|
'res_id': picking.id,
|
|
})
|
|
picking.message_post(body=_('Order %s was sent to the carrier.', picking.name), attachment_ids=attachment_id.ids)
|
|
else:
|
|
picking.message_post(body=_(
|
|
'Error: %(file_name)s file could not be obtained for order %(order_name)s.',
|
|
file_name=pdf_name, order_name=picking.name
|
|
))
|
|
else:
|
|
picking.message_post(body=_('Order %(order)s was already sent to the carrier during label creation.\n'
|
|
'Manifest number: %(manifest_number)s',
|
|
order=picking.name, manifest_number=order_data['order']['manifest_number']))
|
|
# In test mode, we will archive the order instead to avoid any fees related to the end carrier.
|
|
else:
|
|
if manifested:
|
|
picking.message_post(body=_('Order %(order)s was sent to the carrier during label creation.'
|
|
'As you are in a test environment, please make sure to cancel the order with your carrier directly.\n'
|
|
'Manifest number: %(manifest_number)s',
|
|
order=picking.name, manifest_number=order_data['order']['manifest_number']))
|
|
self.starshipit_cancel_shipment(picking)
|
|
picking.message_post(body=_('Order %s was archived.', picking.name))
|
|
return res
|
|
|
|
def get_starshipit_price_update(self, pickings_to_update):
|
|
"""
|
|
Called by the cron job to fetch prices for a batch of pickings.
|
|
This method updates the picking's carrier_price and triggers SO update.
|
|
"""
|
|
self.ensure_one()
|
|
starshipit = self._get_starshipit()
|
|
|
|
for picking in pickings_to_update:
|
|
starshipit_order_id = picking.starshipit_parcel_reference
|
|
try:
|
|
order_data = starshipit._get_order_details(starshipit_order_id)
|
|
total_shipping_price = order_data.get('order', {}).get('total_shipping_price')
|
|
|
|
if total_shipping_price is not None:
|
|
final_carrier_price = self.with_context(order=picking.sale_id)._apply_margins(total_shipping_price)
|
|
|
|
picking.write({
|
|
'carrier_price': final_carrier_price,
|
|
})
|
|
|
|
if self.invoice_policy == 'real' and picking.sale_id:
|
|
if picking.sale_id.invoice_status == 'invoiced':
|
|
picking.sale_id.message_post(body=_(
|
|
"Starshipit: Exact shipping cost for delivery %(picking_name)s is now %(final_carrier_price)s. This sale order is already invoiced, please review if a manual adjustment to the invoice is needed.",
|
|
picking_name=picking.name, final_carrier_price=final_carrier_price
|
|
))
|
|
else:
|
|
try:
|
|
picking._add_delivery_cost_to_so()
|
|
picking.sale_id.message_post(body=_(
|
|
"Starshipit: Delivery cost re-evaluated on Sales Order based on updated exact price (%(final_carrier_price)s) from transfer %(picking_name)s.",
|
|
final_carrier_price=final_carrier_price,
|
|
picking_name=picking.name
|
|
))
|
|
self.env['mail.activity'].create({
|
|
'res_model_id': self.env['ir.model']._get_id('sale.order'),
|
|
'res_id': picking.sale_id.id,
|
|
'user_id': picking.sale_id.user_id.id,
|
|
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
|
'note': _('Shipping Cost has been updated from Starshipit. The Sales Order can now be invoiced.'),
|
|
'summary': _('Starshipit: Shipping Cost updated'),
|
|
'automated': True,
|
|
})
|
|
except Exception as e_so:
|
|
_logger.exception("Starshipit Price Update: Error updating SO %s for picking %s", picking.sale_id.name, picking.name)
|
|
picking.message_post(body=_(
|
|
"Starshipit: Exact shipping cost for delivery %(picking_name)s is now %(final_carrier_price)s, but automatic sale order delivery cost re-evaluation failed: %(e_so)s",
|
|
picking_name=picking.name,
|
|
final_carrier_price=final_carrier_price,
|
|
e_so=e_so
|
|
))
|
|
else:
|
|
_logger.info("Starshipit Price Update: Price still not available from Starshipit for Order ID %s (Picking %s). Will retry in next cron run.", starshipit_order_id, picking.name)
|
|
|
|
# Sleep for 1 second to avoid hitting the Starshipit API rate limit.
|
|
sleep(1)
|
|
|
|
except Exception as e:
|
|
picking.message_post(body=_("Starshipit Price Update Cron: System Error fetching price: %s", str(e)))
|
|
_logger.exception("Starshipit Price Update: System Error for Picking %s (Starshipit ID %s)", picking.name, starshipit_order_id)
|
|
return True
|
|
|
|
def starshipit_get_tracking_link(self, picking):
|
|
""" Get the tracking link for the given picking.
|
|
"""
|
|
starshipit = self._get_starshipit()
|
|
starshipit_order_number = starshipit._get_starshipit_order_number(picking)
|
|
result = starshipit._get_tracking_link(starshipit_order_number)
|
|
return result.get('results', {}).get('tracking_url', False)
|
|
|
|
def starshipit_cancel_shipment(self, pickings):
|
|
""" Archive the shipment on starshipit side.
|
|
Note that this will not do anything with the carrier and the user is expected to handle that himself.
|
|
This is done instead of trying to cancel as we always label right away and once labelled, we cannot cancel anymore.
|
|
"""
|
|
starshipit = self._get_starshipit()
|
|
for picking in pickings:
|
|
starshipit._archive_order(picking.starshipit_parcel_reference)
|
|
|
|
def starshipit_action_load_shipping_carriers(self):
|
|
""" The deliveryservices endpoint is used to get the list of available carriers.
|
|
|
|
As we need to give an address, we will use the company one.
|
|
"""
|
|
self.ensure_one()
|
|
if self.delivery_type != 'starshipit':
|
|
raise ValidationError(_('This action requires a Starshipit carrier.'))
|
|
starshipit = self._get_starshipit()
|
|
order_vals = self.env.context.get('order_vals', {})
|
|
order = self.env['sale.order'].browse(order_vals.get('order_id'))
|
|
origin_partner = order.warehouse_id.partner_id or self.starshipit_origin_address
|
|
|
|
if order_vals.get('destination_partner_id'):
|
|
available_services = starshipit._get_delivery_services_with_destination(
|
|
origin_partner,
|
|
self.env['res.partner'].browse(order_vals.get('destination_partner_id')),
|
|
order_vals.get('total_weight', None),
|
|
)
|
|
else:
|
|
available_services = starshipit._get_delivery_services(
|
|
origin_partner
|
|
)
|
|
if not available_services.get('services'):
|
|
raise UserError(_("There are no shipping services available, please verify the shipping address or activate suitable carriers in your starshipit account."))
|
|
|
|
return {
|
|
'name': _("Choose Starshipit Shipping Service"),
|
|
'type': 'ir.actions.act_window',
|
|
'view_type': 'form',
|
|
'view_mode': 'form',
|
|
'res_model': 'starshipit.shipping.wizard',
|
|
'target': 'new',
|
|
'context': {
|
|
'create_new_carrier': self.env.context.get('create_new_carrier', False),
|
|
'order_vals': order_vals,
|
|
'default_carrier_id': self.id,
|
|
'default_available_services': available_services['services'],
|
|
'default_selected_service_code': self.starshipit_service_code,
|
|
},
|
|
}
|
|
|
|
def starshipit_get_return_label(self, picking, tracking_number=None, origin_date=None):
|
|
""" Generate a return order/label for the given picking.
|
|
The flow is very similar to the send shipping flow, with a few differences.
|
|
"""
|
|
starshipit = self._get_starshipit()
|
|
if picking.starshipit_parcel_reference:
|
|
order = starshipit._clone_order(picking.starshipit_parcel_reference)['order']
|
|
else:
|
|
starshipit_order_number = starshipit._get_starshipit_order_number(picking)
|
|
order = starshipit._create_orders(self, picking, True)['orders'].get(starshipit_order_number)
|
|
|
|
order_id = order['order_id']
|
|
order_number = order['order_number']
|
|
label_data = self._create_label_for_order(order_id, order_number)
|
|
|
|
# if picking is not a return means we are pre-generating the return label on delivery
|
|
# thus we save the returned parcel id in a separate field
|
|
if picking.is_return_picking:
|
|
picking.starshipit_parcel_reference = order['order_id']
|
|
else:
|
|
picking.starshipit_return_parcel_reference = order['order_id']
|
|
|
|
attachment_data = []
|
|
# Attach the labels we got to the picking.
|
|
for label in label_data['labels']:
|
|
attachment_data.append({
|
|
'name': f'{self.get_return_label_prefix()}-{picking.name.replace("/", "_").lower()}.pdf',
|
|
'datas': label,
|
|
'type': 'binary',
|
|
'res_model': picking._name,
|
|
'res_id': picking.id,
|
|
})
|
|
attachment_ids = self.env['ir.attachment'].create(attachment_data)
|
|
# Before continuing, get the order info and check if it was automatically manifested.
|
|
order_data = starshipit._get_order_details(order_id)
|
|
manifested = order_data['order']['manifested']
|
|
if attachment_ids:
|
|
picking.message_post(body=_('Return labels were generated for the order %s', picking.name), attachment_ids=attachment_ids.ids)
|
|
# In production, we can manifest the order, and it will be sent to the carrier.
|
|
if self.prod_environment:
|
|
if not manifested:
|
|
result = starshipit._manifest_orders([picking.starshipit_parcel_reference])
|
|
# Starshipit API doesn't return a PDF for TNT service
|
|
datas = result.get('pdf')
|
|
pdf_name = f'{self._get_delivery_doc_prefix()}-{picking.name.replace("/", "_").lower()}.pdf'
|
|
if datas:
|
|
attachment_id = self.env['ir.attachment'].create({
|
|
'name': pdf_name,
|
|
'datas': datas,
|
|
'type': 'binary',
|
|
'res_model': picking._name,
|
|
'res_id': picking.id,
|
|
})
|
|
picking.message_post(body=_('Return order %s was sent to the carrier.', picking.name), attachment_ids=attachment_id.ids)
|
|
else:
|
|
picking.message_post(body=_(
|
|
'Error: %(file_name)s file could not be obtained for order %(order_name)s.',
|
|
file_name=pdf_name, order_name=picking.name
|
|
))
|
|
else:
|
|
picking.message_post(body=_('Return order %(order)s was already sent to the carrier during label creation.\n'
|
|
'Manifest number: %(manifest_number)s',
|
|
order=picking.name, manifest_number=order_data['order']['manifest_number']))
|
|
# In test mode, we will archive the order instead to avoid any fees related to the end carrier.
|
|
else:
|
|
if manifested:
|
|
picking.message_post(body=_('Return order %(order)s was sent to the carrier during label creation.'
|
|
'As you are in a test environment, please make sure to cancel the order with your carrier directly.\n'
|
|
'Manifest number: %(manifest_number)s',
|
|
order=picking.name, manifest_number=order_data['order']['manifest_number']))
|
|
self.starshipit_cancel_shipment(picking)
|
|
picking.message_post(body=_('Return order %s was archived.', picking.name))
|
|
|
|
def _create_label_for_order(self, order_id, order_number=None):
|
|
starshipit = self._get_starshipit()
|
|
try:
|
|
return starshipit._create_label(order_id)
|
|
|
|
except UserError as e:
|
|
if order_number:
|
|
order_info = _("The order '%(order_number)s' has been created!\n", order_number=order_number)
|
|
else:
|
|
order_info = _("The order has been created!\n")
|
|
|
|
error_message = _(
|
|
"%(order_info)s"
|
|
"However, the shipping label creation failed with the following error:\n%(error)s\n\n"
|
|
"Please either continue the configuration or delete the order in Starshipit "
|
|
"before trying again.",
|
|
order_info=order_info,
|
|
error=e,
|
|
)
|
|
raise UserError(error_message)
|
|
|
|
# API HELPERS #
|
|
|
|
def _starshipit_get_package_information(self, order=False, picking=False):
|
|
""" Given an order or a picking, this method returns the formatted package information to send to Starshipit.
|
|
It also returns the list of items in the package in case the endpoint needs it.
|
|
The method also makes sure that the UOM used in the package information matches the one used by Starshipit. (Kgm and meter)
|
|
"""
|
|
packages = []
|
|
package_items = []
|
|
original_weight_uom = self.env['product.template'].sudo()._get_weight_uom_id_from_ir_config_parameter()
|
|
target_weight_uom = self.env.ref('uom.product_uom_kgm')
|
|
original_length_uom = self.env['product.template'].sudo()._get_length_uom_id_from_ir_config_parameter()
|
|
target_length_uom = self.env.ref('uom.product_uom_meter')
|
|
if picking:
|
|
# Will get the precise package information, with the packages set on the picking if any.
|
|
# When used to rate the order, this will be accurate.
|
|
delivery_packages = self._get_packages_from_picking(picking, self.starshipit_default_package_type_id)
|
|
elif order:
|
|
# Will get the package information based on the default package on the carrier.
|
|
# When used to rate the order, this could be inaccurate.
|
|
delivery_packages = self._get_packages_from_order(order, self.starshipit_default_package_type_id)
|
|
else:
|
|
return [], []
|
|
|
|
for package in delivery_packages:
|
|
for commodity in package.commodities:
|
|
hs_code = commodity.product_id.hs_code or ''
|
|
for ch in [' ', '.']:
|
|
if ch in hs_code:
|
|
hs_code = hs_code.replace(ch, '')
|
|
package_items.append({
|
|
'description': commodity.product_id.name,
|
|
'sku': commodity.product_id.barcode or '',
|
|
'tariff_code': hs_code,
|
|
'country_of_origin': commodity.country_of_origin or '',
|
|
'quantity': commodity.qty,
|
|
'weight': original_weight_uom._compute_quantity(commodity.product_id.weight, target_weight_uom),
|
|
'value': commodity.monetary_value,
|
|
})
|
|
package_val = {
|
|
'weight': original_weight_uom._compute_quantity(package.weight, target_weight_uom),
|
|
'height': original_length_uom._compute_quantity(package.dimension.get('height', 0.0), target_length_uom),
|
|
'width': original_length_uom._compute_quantity(package.dimension.get('width', 0.0), target_length_uom),
|
|
'length': original_length_uom._compute_quantity(package.dimension.get('length', 0.0), target_length_uom),
|
|
}
|
|
packages.append(package_val)
|
|
|
|
return packages, package_items
|
|
|
|
def get_return_label(self, pickings, tracking_number=None, origin_date=None):
|
|
""" Log a warning if the user tries to generate a return label for a carrier that doesn't support it. """
|
|
self.ensure_one()
|
|
if not self.can_generate_return and self.delivery_type == 'starshipit':
|
|
for picking in pickings:
|
|
picking.message_post(body=_('Starshipit cannot generate returns for the carrier %s. '
|
|
'Please handle this return with the carrier directly.', self.starshipit_carrier_code))
|
|
return super().get_return_label(pickings, tracking_number=tracking_number, origin_date=origin_date)
|
|
|
|
def _get_starshipit(self):
|
|
return Starshipit(
|
|
self.starshipit_api_key,
|
|
self.starshipit_subscription_key,
|
|
self.log_xml,
|
|
)
|