forked from Mapan/odoo17e
373 lines
19 KiB
Python
373 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import UserError, ValidationError, RedirectWarning
|
|
from odoo.tools import float_is_zero, float_round
|
|
|
|
from .sendcloud_service import SendCloud
|
|
|
|
|
|
class DeliveryCarrier(models.Model):
|
|
_inherit = "delivery.carrier"
|
|
|
|
delivery_type = fields.Selection(selection_add=[
|
|
('sendcloud', 'Sendcloud')
|
|
], ondelete={'sendcloud': lambda records: records.write({'delivery_type': 'fixed', 'fixed_price': 0})})
|
|
|
|
country_id = fields.Many2one('res.country', string='Ship From', compute='_compute_country_id', store=True, readonly=False)
|
|
sendcloud_public_key = fields.Char(help="Sendcloud API Integration Public key", groups="base.group_system")
|
|
sendcloud_secret_key = fields.Char(help="Sendcloud API Integration Secret key", groups="base.group_system")
|
|
sendcloud_default_package_type_id = fields.Many2one("stock.package.type", string="Default Package Type for Sendcloud", help="Some carriers require package dimensions, you can define these in a package type that you set as default")
|
|
sendcloud_shipping_id = fields.Many2one('sendcloud.shipping.product', store=True, compute='_compute_sendcloud_shipping_id', copy=False)
|
|
sendcloud_return_id = fields.Many2one('sendcloud.shipping.product', store=True, compute='_compute_sendcloud_return_id', copy=False)
|
|
sendcloud_shipping_name = fields.Char(related='sendcloud_shipping_id.name', string="Sendcloud Shipping Product")
|
|
sendcloud_return_name = fields.Char(related='sendcloud_return_id.name', string="Sendcloud Return Shipping Product")
|
|
sendcloud_shipping_rules = fields.Boolean(string="Use Sendcloud shipping rules",
|
|
help="Depending your Sendcloud account type, through rules you can define the shipping method to use depending on different conditions like destination, weight, value, etc.\nRules can override shipping product selected in Odoo")
|
|
sendcloud_product_functionalities = fields.Json(string="Functionalities")
|
|
sendcloud_has_custom_functionalities = fields.Boolean(
|
|
related="sendcloud_shipping_id.can_customize_functionalities")
|
|
sendcloud_can_batch_shipping = fields.Boolean(
|
|
related="sendcloud_shipping_id.has_multicollo")
|
|
sendcloud_use_batch_shipping = fields.Boolean(
|
|
string="Use Batch Shipping",
|
|
help="When sending multiple parcels, combine them in one shipment. Not supported for international shipping requiring customs' documentation",)
|
|
|
|
@api.constrains('delivery_type', 'sendcloud_public_key', 'sendcloud_secret_key')
|
|
def _check_sendcloud_api_keys(self):
|
|
for rec in self:
|
|
if rec.delivery_type == 'sendcloud' and not (rec.sudo().sendcloud_public_key and rec.sudo().sendcloud_secret_key):
|
|
raise ValidationError(_('You must add your public and secret key for sendcloud delivery type!'))
|
|
|
|
@api.depends('delivery_type')
|
|
def _compute_can_generate_return(self):
|
|
super()._compute_can_generate_return()
|
|
self.filtered(lambda c: c.delivery_type == 'sendcloud').can_generate_return = True
|
|
|
|
@api.depends('country_id')
|
|
def _compute_sendcloud_shipping_id(self):
|
|
self.sendcloud_shipping_id = False
|
|
|
|
@api.depends('country_id')
|
|
def _compute_sendcloud_return_id(self):
|
|
self.sendcloud_return_id = False
|
|
|
|
def write(self, vals):
|
|
original_sendcloud_product_ids = set(self.sendcloud_shipping_id.ids + self.sendcloud_return_id.ids)
|
|
res = super().write(vals)
|
|
to_delete_sendcloud_product_ids = original_sendcloud_product_ids - set(self.sendcloud_shipping_id.ids + self.sendcloud_return_id.ids)
|
|
if to_delete_sendcloud_product_ids:
|
|
self.env['sendcloud.shipping.product'].browse(to_delete_sendcloud_product_ids).unlink()
|
|
return res
|
|
|
|
def action_load_sendcloud_shipping_products(self):
|
|
"""
|
|
Returns a wizard to choose from available sendcloud shipping products.
|
|
Since the shipping product ids in sendcloud change overtime they are not saved,
|
|
instead they are fetched everytime and passed to the context of the wizard
|
|
"""
|
|
self.ensure_one()
|
|
if self.delivery_type != 'sendcloud':
|
|
raise ValidationError(_('Must be a Sendcloud carrier!'))
|
|
if not self.country_id:
|
|
raise UserError(_("You must assign the required 'Shipping From' field in order to search for available products"))
|
|
sendcloud = self._get_sendcloud()
|
|
# Get normal and return shipping products (can't get both at once)
|
|
shipping_products = sendcloud._get_shipping_products(from_country=self.country_id.code)
|
|
return_products = sendcloud._get_shipping_products(from_country=self.country_id.code, is_return=True)
|
|
if not shipping_products:
|
|
raise UserError(_("There are no shipping products available, please update the 'Shipping From' field or activate suitable carriers in your sendcloud account"))
|
|
|
|
return {
|
|
'name': _("Choose Sendcloud Shipping Products"),
|
|
'type': 'ir.actions.act_window',
|
|
'view_type': 'form',
|
|
'view_mode': 'form',
|
|
'res_model': 'sendcloud.shipping.wizard',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_carrier_id': self.id,
|
|
'default_shipping_products': shipping_products,
|
|
'default_return_products': return_products,
|
|
'default_sendcloud_products_code': {
|
|
'shipping': self.sendcloud_shipping_id.sendcloud_code or shipping_products[0].get('code', False),
|
|
'return': self.sendcloud_return_id.sendcloud_code or False,
|
|
},
|
|
},
|
|
}
|
|
|
|
def rate_shipment(self, order):
|
|
res = super().rate_shipment(order)
|
|
if hasattr(self, '%s_rate_shipment' % self.delivery_type):
|
|
if res.get('no_rate'):
|
|
res['warning_message'] = _('There is no rate available for this order with the selected shipping product.')
|
|
return res
|
|
|
|
def sendcloud_rate_shipment(self, order):
|
|
""" Returns shipping rate for the order and chosen shipping method """
|
|
order_weight = self.env.context.get('order_weight', None)
|
|
sendcloud = self._get_sendcloud()
|
|
try:
|
|
result = sendcloud._get_shipping_rate(self, order=order, order_weight=order_weight)
|
|
if not result:
|
|
return {
|
|
'success': True,
|
|
'price': 0.0,
|
|
'no_rate': True,
|
|
}
|
|
price, packages_no = result
|
|
except (UserError, ValidationError) as e:
|
|
return {
|
|
'success': False,
|
|
'price': 0.0,
|
|
'error_message': str(e),
|
|
}
|
|
messages = []
|
|
if packages_no > 1:
|
|
messages.append(_("Note that this price is for %s packages since the order weight is more than the maximum weight allowed by the shipping method.", packages_no))
|
|
|
|
# Check if the products individually fit in the delivery method
|
|
max_weight_user_uom = self.sendcloud_convert_weight(self.sendcloud_shipping_id.max_weight - 1, grams=True, reverse=True)
|
|
for sol in order.order_line:
|
|
# We only assume the following as a warning as there's no notion of unbreakable unit in Odoo
|
|
if sol.product_id.weight > max_weight_user_uom:
|
|
messages.append(_("Note that a unit of the product '%s' is heavier than the maximum weight allowed by the shipping method.", sol.product_id.name))
|
|
break
|
|
message = "\n".join(messages) if messages else None
|
|
return {
|
|
'success': True,
|
|
'price': price,
|
|
'warning_message': message
|
|
}
|
|
|
|
def sendcloud_send_shipping(self, pickings):
|
|
''' Sends Shipment to sendcloud, must request rate to return exact price '''
|
|
sendcloud = self._get_sendcloud()
|
|
res = []
|
|
for pick in pickings:
|
|
# multiple parcels if several packages used
|
|
parcels = sendcloud._send_shipment(pick)
|
|
# fetch the ids, tracking numbers and url for each parcel
|
|
parcel_ids, parcel_tracking_numbers, doc_ids = self._prepare_track_message_docs(pick, parcels, sendcloud)
|
|
pick.message_post_with_source(
|
|
'delivery_sendcloud.sendcloud_label_tracking',
|
|
render_values={'type': 'Shipment', 'parcels': parcels},
|
|
subtype_xmlid='mail.mt_note',
|
|
attachment_ids=doc_ids.ids,
|
|
)
|
|
pick.sendcloud_parcel_ref = parcel_ids
|
|
try:
|
|
# generate return if config is set
|
|
if pick.carrier_id.return_label_on_delivery:
|
|
self.get_return_label(pick)
|
|
except UserError:
|
|
# if the return fails need to log that they failed and continue
|
|
pick.message_post(body=_('Failed to create the return label!'))
|
|
|
|
try:
|
|
# get exact price of shipment
|
|
price = 0.0
|
|
for parcel in parcels:
|
|
# get price for each parcel
|
|
shipping_rate = sendcloud._get_shipping_rate(pick.carrier_id, picking=pick, parcel=parcel)
|
|
if shipping_rate:
|
|
price += shipping_rate[0]
|
|
except UserError:
|
|
# if the price fetch fails need to log that they failed and continue
|
|
pick.message_post(body=_('Failed to get the actual price!'))
|
|
|
|
# get tracking numbers for parcels
|
|
parcel_tracking_numbers = ','.join(parcel_tracking_numbers)
|
|
# if in test env, sendcloud does not have one, so we cancel the shipment ASAP
|
|
if not self.prod_environment:
|
|
self.cancel_shipment(pick)
|
|
msg = _("Shipment %s cancelled", parcel_tracking_numbers)
|
|
pick.message_post(body=msg)
|
|
parcel_tracking_numbers = None
|
|
res.append({
|
|
'exact_price': price,
|
|
'tracking_number': parcel_tracking_numbers
|
|
})
|
|
return res
|
|
|
|
def sendcloud_get_tracking_link(self, picking):
|
|
sendcloud = self._get_sendcloud()
|
|
# since there can be more than one id stored, comma seperated, only the first will be tracked
|
|
parcel_id = picking.sendcloud_parcel_ref[0]
|
|
if isinstance(parcel_id, list): # Multicollo
|
|
parcel_id = parcel_id[0]
|
|
res = sendcloud._track_shipment(parcel_id)
|
|
return res['tracking_url']
|
|
|
|
def sendcloud_get_return_label(self, picking, tracking_number=None, origin_date=None):
|
|
sendcloud = self._get_sendcloud()
|
|
parcels = sendcloud._send_shipment(picking=picking, is_return=True)
|
|
# fetch the ids, tracking numbers and url for each parcel
|
|
parcel_ids, _, doc_ids = self._prepare_track_message_docs(picking, parcels, sendcloud)
|
|
# Add Tracking info and docs in chatter
|
|
picking.message_post_with_source(
|
|
'delivery_sendcloud.sendcloud_label_tracking',
|
|
render_values={'type': 'Return', 'parcels': parcels},
|
|
subtype_xmlid='mail.mt_note',
|
|
attachment_ids=doc_ids.ids
|
|
)
|
|
# if picking is not a return means we are pregenerating the return label on delivery
|
|
# thus we save the returned parcel id in a seperate field
|
|
if picking.is_return_picking:
|
|
picking.sendcloud_parcel_ref = parcel_ids
|
|
else:
|
|
picking.sendcloud_return_parcel_ref = parcel_ids
|
|
|
|
def sendcloud_cancel_shipment(self, pickings):
|
|
sendcloud = self._get_sendcloud()
|
|
failed_call = []
|
|
for pick in pickings:
|
|
parcels = (pick.sendcloud_parcel_ref or []) + (pick.sendcloud_return_parcel_ref or [])
|
|
for parcel_id in parcels:
|
|
if isinstance(parcel_id, list):
|
|
# In Multicollo, cancelling 1 parcel of the bactch cancel the whole batch
|
|
parcel_id = parcel_id[0]
|
|
res = sendcloud._cancel_shipment(parcel_id)
|
|
if res.get('status') not in ['deleted', 'cancelled', 'queued']:
|
|
failed_call.append(parcel_id)
|
|
if failed_call:
|
|
details = ",".join(str(p_id) for p_id in failed_call)
|
|
raise UserError(f"The cancellation was rejected for the parcel(s) with the following id :\n{details}\nEither :\n\t - The parcel is already cancelled\n\t - The parcel has been announced more than 42 days ago\n\t - The parcel has already been delivered")
|
|
|
|
def sendcloud_convert_weight(self, weight, grams=False, reverse=False):
|
|
"""
|
|
Each API request for sendcloud usually requires
|
|
weight in kilograms but pricing supports grams.
|
|
"""
|
|
from_uom_id = self.env['product.template'].sudo()._get_weight_uom_id_from_ir_config_parameter()
|
|
to_uom_id = self.env.ref('uom.product_uom_gram') if grams else self.env.ref('uom.product_uom_kgm')
|
|
if reverse:
|
|
from_uom_id, to_uom_id = to_uom_id, from_uom_id
|
|
if float_is_zero(weight, precision_rounding=from_uom_id.rounding):
|
|
return weight
|
|
return from_uom_id._compute_quantity(weight, to_uom_id)
|
|
|
|
def sendcloud_convert_length(self, length, reverse=False, unit="cm"):
|
|
"""
|
|
Each API request for sendcloud usually requires length in centimeters but also supports millimeters and meters.\n
|
|
Length sent to Sendcloud must be of type integer as Sendcloud doesn't support floating numbers in its API.
|
|
:param delivery.carrier self: the Sendcloud delivery carrier
|
|
:param float length: the original length in the user default's UoM
|
|
:param bool reverse: reverse the source and destination units of the conversion, default to False
|
|
:param str unit: The destination unit, default to "cm", can also be "mm" or "m"
|
|
:return: The converted length rounded up as an integer
|
|
:rtype: int
|
|
:raises UserError: if 'unit' is not in ("mm", "cm", "m")
|
|
"""
|
|
if length == 0:
|
|
return length
|
|
length_uom_id = self.env['product.template'].sudo()._get_length_uom_id_from_ir_config_parameter()
|
|
dest_uom_id = self.env['uom.uom'].search([('name', 'ilike', unit)])
|
|
if not dest_uom_id:
|
|
raise UserError(_("There's no unit of measure with the name \"%s\".", (unit)))
|
|
if dest_uom_id == length_uom_id:
|
|
return length
|
|
elif reverse:
|
|
dest_uom_id, length_uom_id = length_uom_id, dest_uom_id
|
|
converted_length = length_uom_id._compute_quantity(length, dest_uom_id)
|
|
converted_length = int(float_round(converted_length, precision_rounding=1.0, rounding_method='UP'))
|
|
return converted_length
|
|
|
|
def _set_sendcloud_products(self, shipping_product, return_product):
|
|
self.ensure_one()
|
|
# delete old shipping product since it will be replaced
|
|
# self.sendcloud_shipping_id.unlink()
|
|
products_to_create = [{
|
|
'name': shipping_product['name'],
|
|
'sendcloud_code': shipping_product['code'],
|
|
'carrier': shipping_product['carrier'],
|
|
'min_weight': shipping_product['weight_range']['min_weight'],
|
|
'max_weight': shipping_product['weight_range']['max_weight'],
|
|
'functionalities': shipping_product['local_cache']['functionalities'],
|
|
}]
|
|
if return_product:
|
|
# self.sendcloud_return_id.sudo().unlink()
|
|
products_to_create.append({
|
|
'name': return_product['name'],
|
|
'sendcloud_code': return_product['code'],
|
|
'carrier': return_product['carrier'],
|
|
'min_weight': return_product['weight_range']['min_weight'],
|
|
'max_weight': return_product['weight_range']['max_weight'],
|
|
'functionalities': return_product['local_cache']['functionalities'],
|
|
})
|
|
products = self.env['sendcloud.shipping.product'].create(products_to_create)
|
|
if return_product:
|
|
self.sendcloud_shipping_id = products[0]
|
|
self.sendcloud_return_id = products[1]
|
|
else:
|
|
self.sendcloud_shipping_id = products
|
|
self.sendcloud_return_id = False
|
|
self.sendcloud_product_functionalities = {}
|
|
if not self.sendcloud_can_batch_shipping:
|
|
self.sendcloud_use_batch_shipping = False
|
|
return True
|
|
|
|
def raise_redirect_message(self):
|
|
self.ensure_one()
|
|
message = _('You must have a shipping product configured!')
|
|
|
|
if self.sendcloud_shipping_id:
|
|
message = _("The shipping product actually configured can't handle this delivery")
|
|
raise RedirectWarning(
|
|
message,
|
|
{
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'delivery.carrier',
|
|
'res_id': self.id,
|
|
'views': [[False, 'form']],
|
|
},
|
|
_('Go to the shipping product'),
|
|
)
|
|
|
|
@api.depends("delivery_type")
|
|
def _compute_country_id(self):
|
|
for dc in self:
|
|
country = self.env['res.country']
|
|
if dc.delivery_type == 'sendcloud':
|
|
if dc.country_id:
|
|
continue
|
|
company = dc.company_id or self.env.company
|
|
default_warehouse = self.env.user.with_company(company.id)._get_default_warehouse_id()
|
|
if default_warehouse:
|
|
country = default_warehouse.partner_id.country_id
|
|
if not country and company.country_id:
|
|
country = company.country_id
|
|
dc.country_id = country
|
|
|
|
def _get_sendcloud(self):
|
|
return SendCloud(self.sudo().sendcloud_public_key, self.sudo().sendcloud_secret_key, self.log_xml)
|
|
|
|
def _prepare_track_message_docs(self, picking, parcels, sendcloud):
|
|
docs = []
|
|
parcel_ids = {}
|
|
parcel_tracking_numbers = []
|
|
for parcel in parcels:
|
|
parcel_ids.setdefault(parcel['colli_uuid'], []).append(parcel['id'])
|
|
parcel_tracking_numbers.append(parcel.get('tracking_number'))
|
|
# this will include documents to print such as label
|
|
# https://api.sendcloud.dev/docs/sendcloud-public-api/parcel-documents/operations/get-a-parcel-document
|
|
# sendcloud docs mention there are 7 doc types
|
|
# so we limit the loop to 7 docs
|
|
for doc in parcel['documents'][:7]:
|
|
doc_content = sendcloud._get_document(doc['link'])
|
|
if doc['type'].lower() == 'label':
|
|
doc_title = f"{self._get_delivery_label_prefix()}-{parcel['id']}.pdf"
|
|
else:
|
|
doc_type = doc['type'].capitalize()
|
|
doc_title = f"{self._get_delivery_doc_prefix()}-{doc_type}-{parcel['id']}.pdf"
|
|
docs.append({
|
|
'name': doc_title,
|
|
'type': 'binary',
|
|
'raw': doc_content,
|
|
'res_model': picking._name,
|
|
'res_id': picking.id
|
|
})
|
|
doc_ids = self.env['ir.attachment'].create(docs)
|
|
|
|
return list(parcel_ids.values()), parcel_tracking_numbers, doc_ids
|