1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/delivery_sendcloud/models/delivery_carrier.py
2024-12-10 09:04:09 +07:00

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