summaryrefslogtreecommitdiff
path: root/addons/delivery/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/delivery/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/delivery/models')
-rw-r--r--addons/delivery/models/__init__.py11
-rw-r--r--addons/delivery/models/delivery_carrier.py279
-rw-r--r--addons/delivery/models/delivery_grid.py138
-rw-r--r--addons/delivery/models/partner.py10
-rw-r--r--addons/delivery/models/product_packaging.py48
-rw-r--r--addons/delivery/models/product_template.py13
-rw-r--r--addons/delivery/models/sale_order.py189
-rw-r--r--addons/delivery/models/stock_move.py57
-rw-r--r--addons/delivery/models/stock_picking.py262
9 files changed, 1007 insertions, 0 deletions
diff --git a/addons/delivery/models/__init__.py b/addons/delivery/models/__init__.py
new file mode 100644
index 00000000..72fc7b3a
--- /dev/null
+++ b/addons/delivery/models/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import delivery_carrier
+from . import delivery_grid
+from . import product_packaging
+from . import product_template
+from . import sale_order
+from . import partner
+from . import stock_picking
+from . import stock_move
diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py
new file mode 100644
index 00000000..f4ff63f9
--- /dev/null
+++ b/addons/delivery/models/delivery_carrier.py
@@ -0,0 +1,279 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import psycopg2
+
+from odoo import api, fields, models, registry, SUPERUSER_ID, _
+
+_logger = logging.getLogger(__name__)
+
+
+class DeliveryCarrier(models.Model):
+ _name = 'delivery.carrier'
+ _description = "Shipping Methods"
+ _order = 'sequence, id'
+
+ ''' A Shipping Provider
+
+ In order to add your own external provider, follow these steps:
+
+ 1. Create your model MyProvider that _inherit 'delivery.carrier'
+ 2. Extend the selection of the field "delivery_type" with a pair
+ ('<my_provider>', 'My Provider')
+ 3. Add your methods:
+ <my_provider>_rate_shipment
+ <my_provider>_send_shipping
+ <my_provider>_get_tracking_link
+ <my_provider>_cancel_shipment
+ _<my_provider>_get_default_custom_package_code
+ (they are documented hereunder)
+ '''
+
+ # -------------------------------- #
+ # Internals for shipping providers #
+ # -------------------------------- #
+
+ name = fields.Char('Delivery Method', required=True, translate=True)
+ active = fields.Boolean(default=True)
+ sequence = fields.Integer(help="Determine the display order", default=10)
+ # This field will be overwritten by internal shipping providers by adding their own type (ex: 'fedex')
+ delivery_type = fields.Selection([('fixed', 'Fixed Price')], string='Provider', default='fixed', required=True)
+ integration_level = fields.Selection([('rate', 'Get Rate'), ('rate_and_ship', 'Get Rate and Create Shipment')], string="Integration Level", default='rate_and_ship', help="Action while validating Delivery Orders")
+ prod_environment = fields.Boolean("Environment", help="Set to True if your credentials are certified for production.")
+ debug_logging = fields.Boolean('Debug logging', help="Log requests in order to ease debugging")
+ company_id = fields.Many2one('res.company', string='Company', related='product_id.company_id', store=True, readonly=False)
+ product_id = fields.Many2one('product.product', string='Delivery Product', required=True, ondelete='restrict')
+
+ invoice_policy = fields.Selection([
+ ('estimated', 'Estimated cost'),
+ ('real', 'Real cost')
+ ], string='Invoicing Policy', default='estimated', required=True,
+ help="Estimated Cost: the customer will be invoiced the estimated cost of the shipping.\nReal Cost: the customer will be invoiced the real cost of the shipping, the cost of the shipping will be updated on the SO after the delivery.")
+
+ country_ids = fields.Many2many('res.country', 'delivery_carrier_country_rel', 'carrier_id', 'country_id', 'Countries')
+ state_ids = fields.Many2many('res.country.state', 'delivery_carrier_state_rel', 'carrier_id', 'state_id', 'States')
+ zip_from = fields.Char('Zip From')
+ zip_to = fields.Char('Zip To')
+
+ margin = fields.Float(help='This percentage will be added to the shipping price.')
+ free_over = fields.Boolean('Free if order amount is above', help="If the order total amount (shipping excluded) is above or equal to this value, the customer benefits from a free shipping", default=False)
+ amount = fields.Float(string='Amount', help="Amount of the order to benefit from a free shipping, expressed in the company currency")
+
+ can_generate_return = fields.Boolean(compute="_compute_can_generate_return")
+ return_label_on_delivery = fields.Boolean(string="Generate Return Label", help="The return label is automatically generated at the delivery.")
+ get_return_label_from_portal = fields.Boolean(string="Return Label Accessible from Customer Portal", help="The return label can be downloaded by the customer from the customer portal.")
+
+ _sql_constraints = [
+ ('margin_not_under_100_percent', 'CHECK (margin >= -100)', 'Margin cannot be lower than -100%'),
+ ]
+
+ @api.depends('delivery_type')
+ def _compute_can_generate_return(self):
+ for carrier in self:
+ carrier.can_generate_return = False
+
+ def toggle_prod_environment(self):
+ for c in self:
+ c.prod_environment = not c.prod_environment
+
+ def toggle_debug(self):
+ for c in self:
+ c.debug_logging = not c.debug_logging
+
+ def install_more_provider(self):
+ return {
+ 'name': 'New Providers',
+ 'view_mode': 'kanban,form',
+ 'res_model': 'ir.module.module',
+ 'domain': [['name', '=like', 'delivery_%'], ['name', '!=', 'delivery_barcode']],
+ 'type': 'ir.actions.act_window',
+ 'help': _('''<p class="o_view_nocontent">
+ Buy Odoo Enterprise now to get more providers.
+ </p>'''),
+ }
+
+ def available_carriers(self, partner):
+ return self.filtered(lambda c: c._match_address(partner))
+
+ def _match_address(self, partner):
+ self.ensure_one()
+ if self.country_ids and partner.country_id not in self.country_ids:
+ return False
+ if self.state_ids and partner.state_id not in self.state_ids:
+ return False
+ if self.zip_from and (partner.zip or '').upper() < self.zip_from.upper():
+ return False
+ if self.zip_to and (partner.zip or '').upper() > self.zip_to.upper():
+ return False
+ return True
+
+ @api.onchange('integration_level')
+ def _onchange_integration_level(self):
+ if self.integration_level == 'rate':
+ self.invoice_policy = 'estimated'
+
+ @api.onchange('can_generate_return')
+ def _onchange_can_generate_return(self):
+ if not self.can_generate_return:
+ self.return_label_on_delivery = False
+
+ @api.onchange('return_label_on_delivery')
+ def _onchange_return_label_on_delivery(self):
+ if not self.return_label_on_delivery:
+ self.get_return_label_from_portal = False
+
+ @api.onchange('state_ids')
+ def onchange_states(self):
+ self.country_ids = [(6, 0, self.country_ids.ids + self.state_ids.mapped('country_id.id'))]
+
+ @api.onchange('country_ids')
+ def onchange_countries(self):
+ self.state_ids = [(6, 0, self.state_ids.filtered(lambda state: state.id in self.country_ids.mapped('state_ids').ids).ids)]
+
+ # -------------------------- #
+ # API for external providers #
+ # -------------------------- #
+
+ def rate_shipment(self, order):
+ ''' Compute the price of the order shipment
+
+ :param order: record of sale.order
+ :return dict: {'success': boolean,
+ 'price': a float,
+ 'error_message': a string containing an error message,
+ 'warning_message': a string containing a warning message}
+ # TODO maybe the currency code?
+ '''
+ self.ensure_one()
+ if hasattr(self, '%s_rate_shipment' % self.delivery_type):
+ res = getattr(self, '%s_rate_shipment' % self.delivery_type)(order)
+ # apply margin on computed price
+ res['price'] = float(res['price']) * (1.0 + (self.margin / 100.0))
+ # save the real price in case a free_over rule overide it to 0
+ res['carrier_price'] = res['price']
+ # free when order is large enough
+ if res['success'] and self.free_over and order._compute_amount_total_without_delivery() >= self.amount:
+ res['warning_message'] = _('The shipping is free since the order amount exceeds %.2f.') % (self.amount)
+ res['price'] = 0.0
+ return res
+
+ def send_shipping(self, pickings):
+ ''' Send the package to the service provider
+
+ :param pickings: A recordset of pickings
+ :return list: A list of dictionaries (one per picking) containing of the form::
+ { 'exact_price': price,
+ 'tracking_number': number }
+ # TODO missing labels per package
+ # TODO missing currency
+ # TODO missing success, error, warnings
+ '''
+ self.ensure_one()
+ if hasattr(self, '%s_send_shipping' % self.delivery_type):
+ return getattr(self, '%s_send_shipping' % self.delivery_type)(pickings)
+
+ def get_return_label(self,pickings, tracking_number=None, origin_date=None):
+ self.ensure_one()
+ if self.can_generate_return:
+ return getattr(self, '%s_get_return_label' % self.delivery_type)(pickings, tracking_number, origin_date)
+
+ def get_return_label_prefix(self):
+ return 'ReturnLabel-%s' % self.delivery_type
+
+ def get_tracking_link(self, picking):
+ ''' Ask the tracking link to the service provider
+
+ :param picking: record of stock.picking
+ :return str: an URL containing the tracking link or False
+ '''
+ self.ensure_one()
+ if hasattr(self, '%s_get_tracking_link' % self.delivery_type):
+ return getattr(self, '%s_get_tracking_link' % self.delivery_type)(picking)
+
+ def cancel_shipment(self, pickings):
+ ''' Cancel a shipment
+
+ :param pickings: A recordset of pickings
+ '''
+ self.ensure_one()
+ if hasattr(self, '%s_cancel_shipment' % self.delivery_type):
+ return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings)
+
+ def log_xml(self, xml_string, func):
+ self.ensure_one()
+
+ if self.debug_logging:
+ self.flush()
+ db_name = self._cr.dbname
+
+ # Use a new cursor to avoid rollback that could be caused by an upper method
+ try:
+ db_registry = registry(db_name)
+ with db_registry.cursor() as cr:
+ env = api.Environment(cr, SUPERUSER_ID, {})
+ IrLogging = env['ir.logging']
+ IrLogging.sudo().create({'name': 'delivery.carrier',
+ 'type': 'server',
+ 'dbname': db_name,
+ 'level': 'DEBUG',
+ 'message': xml_string,
+ 'path': self.delivery_type,
+ 'func': func,
+ 'line': 1})
+ except psycopg2.Error:
+ pass
+
+ def _get_default_custom_package_code(self):
+ """ Some delivery carriers require a prefix to be sent in order to use custom
+ packages (ie not official ones). This optional method will return it as a string.
+ """
+ self.ensure_one()
+ if hasattr(self, '_%s_get_default_custom_package_code' % self.delivery_type):
+ return getattr(self, '_%s_get_default_custom_package_code' % self.delivery_type)()
+ else:
+ return False
+
+ # ------------------------------------------------ #
+ # Fixed price shipping, aka a very simple provider #
+ # ------------------------------------------------ #
+
+ fixed_price = fields.Float(compute='_compute_fixed_price', inverse='_set_product_fixed_price', store=True, string='Fixed Price')
+
+ @api.depends('product_id.list_price', 'product_id.product_tmpl_id.list_price')
+ def _compute_fixed_price(self):
+ for carrier in self:
+ carrier.fixed_price = carrier.product_id.list_price
+
+ def _set_product_fixed_price(self):
+ for carrier in self:
+ carrier.product_id.list_price = carrier.fixed_price
+
+ def fixed_rate_shipment(self, order):
+ carrier = self._match_address(order.partner_shipping_id)
+ if not carrier:
+ return {'success': False,
+ 'price': 0.0,
+ 'error_message': _('Error: this delivery method is not available for this address.'),
+ 'warning_message': False}
+ price = self.fixed_price
+ company = self.company_id or order.company_id or self.env.company
+ if company.currency_id and company.currency_id != order.currency_id:
+ price = company.currency_id._convert(price, order.currency_id, company, fields.Date.today())
+ return {'success': True,
+ 'price': price,
+ 'error_message': False,
+ 'warning_message': False}
+
+ def fixed_send_shipping(self, pickings):
+ res = []
+ for p in pickings:
+ res = res + [{'exact_price': p.carrier_id.fixed_price,
+ 'tracking_number': False}]
+ return res
+
+ def fixed_get_tracking_link(self, picking):
+ return False
+
+ def fixed_cancel_shipment(self, pickings):
+ raise NotImplementedError()
diff --git a/addons/delivery/models/delivery_grid.py b/addons/delivery/models/delivery_grid.py
new file mode 100644
index 00000000..5f52494d
--- /dev/null
+++ b/addons/delivery/models/delivery_grid.py
@@ -0,0 +1,138 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api, _
+from odoo.tools.safe_eval import safe_eval
+from odoo.exceptions import UserError, ValidationError
+
+
+class PriceRule(models.Model):
+ _name = "delivery.price.rule"
+ _description = "Delivery Price Rules"
+ _order = 'sequence, list_price, id'
+
+ @api.depends('variable', 'operator', 'max_value', 'list_base_price', 'list_price', 'variable_factor')
+ def _compute_name(self):
+ for rule in self:
+ name = 'if %s %s %.02f then' % (rule.variable, rule.operator, rule.max_value)
+ if rule.list_base_price and not rule.list_price:
+ name = '%s fixed price %.02f' % (name, rule.list_base_price)
+ elif rule.list_price and not rule.list_base_price:
+ name = '%s %.02f times %s' % (name, rule.list_price, rule.variable_factor)
+ else:
+ name = '%s fixed price %.02f plus %.02f times %s' % (name, rule.list_base_price, rule.list_price, rule.variable_factor)
+ rule.name = name
+
+ name = fields.Char(compute='_compute_name')
+ sequence = fields.Integer(required=True, default=10)
+ carrier_id = fields.Many2one('delivery.carrier', 'Carrier', required=True, ondelete='cascade')
+
+ variable = fields.Selection([('weight', 'Weight'), ('volume', 'Volume'), ('wv', 'Weight * Volume'), ('price', 'Price'), ('quantity', 'Quantity')], required=True, default='weight')
+ operator = fields.Selection([('==', '='), ('<=', '<='), ('<', '<'), ('>=', '>='), ('>', '>')], required=True, default='<=')
+ max_value = fields.Float('Maximum Value', required=True)
+ list_base_price = fields.Float(string='Sale Base Price', digits='Product Price', required=True, default=0.0)
+ list_price = fields.Float('Sale Price', digits='Product Price', required=True, default=0.0)
+ variable_factor = fields.Selection([('weight', 'Weight'), ('volume', 'Volume'), ('wv', 'Weight * Volume'), ('price', 'Price'), ('quantity', 'Quantity')], 'Variable Factor', required=True, default='weight')
+
+
+class ProviderGrid(models.Model):
+ _inherit = 'delivery.carrier'
+
+ delivery_type = fields.Selection(selection_add=[
+ ('base_on_rule', 'Based on Rules'),
+ ], ondelete={'base_on_rule': lambda recs: recs.write({
+ 'delivery_type': 'fixed', 'fixed_price': 0,
+ })})
+ price_rule_ids = fields.One2many('delivery.price.rule', 'carrier_id', 'Pricing Rules', copy=True)
+
+ def base_on_rule_rate_shipment(self, order):
+ carrier = self._match_address(order.partner_shipping_id)
+ if not carrier:
+ return {'success': False,
+ 'price': 0.0,
+ 'error_message': _('Error: this delivery method is not available for this address.'),
+ 'warning_message': False}
+
+ try:
+ price_unit = self._get_price_available(order)
+ except UserError as e:
+ return {'success': False,
+ 'price': 0.0,
+ 'error_message': e.args[0],
+ 'warning_message': False}
+ if order.company_id.currency_id.id != order.pricelist_id.currency_id.id:
+ price_unit = order.company_id.currency_id._convert(
+ price_unit, order.pricelist_id.currency_id, order.company_id, order.date_order or fields.Date.today())
+
+ return {'success': True,
+ 'price': price_unit,
+ 'error_message': False,
+ 'warning_message': False}
+
+ def _get_price_available(self, order):
+ self.ensure_one()
+ self = self.sudo()
+ order = order.sudo()
+ total = weight = volume = quantity = 0
+ total_delivery = 0.0
+ for line in order.order_line:
+ if line.state == 'cancel':
+ continue
+ if line.is_delivery:
+ total_delivery += line.price_total
+ if not line.product_id or line.is_delivery:
+ continue
+ qty = line.product_uom._compute_quantity(line.product_uom_qty, line.product_id.uom_id)
+ weight += (line.product_id.weight or 0.0) * qty
+ volume += (line.product_id.volume or 0.0) * qty
+ quantity += qty
+ total = (order.amount_total or 0.0) - total_delivery
+
+ total = order.currency_id._convert(
+ total, order.company_id.currency_id, order.company_id, order.date_order or fields.Date.today())
+
+ return self._get_price_from_picking(total, weight, volume, quantity)
+
+ def _get_price_dict(self, total, weight, volume, quantity):
+ '''Hook allowing to retrieve dict to be used in _get_price_from_picking() function.
+ Hook to be overridden when we need to add some field to product and use it in variable factor from price rules. '''
+ return {
+ 'price': total,
+ 'volume': volume,
+ 'weight': weight,
+ 'wv': volume * weight,
+ 'quantity': quantity
+ }
+
+ def _get_price_from_picking(self, total, weight, volume, quantity):
+ price = 0.0
+ criteria_found = False
+ price_dict = self._get_price_dict(total, weight, volume, quantity)
+ if self.free_over and total >= self.amount:
+ return 0
+ for line in self.price_rule_ids:
+ test = safe_eval(line.variable + line.operator + str(line.max_value), price_dict)
+ if test:
+ price = line.list_base_price + line.list_price * price_dict[line.variable_factor]
+ criteria_found = True
+ break
+ if not criteria_found:
+ raise UserError(_("No price rule matching this order; delivery cost cannot be computed."))
+
+ return price
+
+ def base_on_rule_send_shipping(self, pickings):
+ res = []
+ for p in pickings:
+ carrier = self._match_address(p.partner_id)
+ if not carrier:
+ raise ValidationError(_('There is no matching delivery rule.'))
+ res = res + [{'exact_price': p.carrier_id._get_price_available(p.sale_id) if p.sale_id else 0.0, # TODO cleanme
+ 'tracking_number': False}]
+ return res
+
+ def base_on_rule_get_tracking_link(self, picking):
+ return False
+
+ def base_on_rule_cancel_shipment(self, pickings):
+ raise NotImplementedError()
diff --git a/addons/delivery/models/partner.py b/addons/delivery/models/partner.py
new file mode 100644
index 00000000..5b19d154
--- /dev/null
+++ b/addons/delivery/models/partner.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ property_delivery_carrier_id = fields.Many2one('delivery.carrier', company_dependent=True, string="Delivery Method", help="Default delivery method used in sales orders.")
diff --git a/addons/delivery/models/product_packaging.py b/addons/delivery/models/product_packaging.py
new file mode 100644
index 00000000..939a6dd0
--- /dev/null
+++ b/addons/delivery/models/product_packaging.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields, api
+
+
+class ProductPackaging(models.Model):
+ _inherit = 'product.packaging'
+
+
+ def _get_default_length_uom(self):
+ return self.env['product.template']._get_length_uom_name_from_ir_config_parameter()
+
+ def _get_default_weight_uom(self):
+ return self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
+
+ height = fields.Integer('Height')
+ width = fields.Integer('Width')
+ packaging_length = fields.Integer('Length')
+ max_weight = fields.Float('Max Weight', help='Maximum weight shippable in this packaging')
+ shipper_package_code = fields.Char('Package Code')
+ package_carrier_type = fields.Selection([('none', 'No carrier integration')], string='Carrier', default='none')
+ weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name', default=_get_default_weight_uom)
+ length_uom_name = fields.Char(string='Length unit of measure label', compute='_compute_length_uom_name', default=_get_default_length_uom)
+
+ _sql_constraints = [
+ ('positive_height', 'CHECK(height>=0)', 'Height must be positive'),
+ ('positive_width', 'CHECK(width>=0)', 'Width must be positive'),
+ ('positive_length', 'CHECK(packaging_length>=0)', 'Length must be positive'),
+ ('positive_max_weight', 'CHECK(max_weight>=0.0)', 'Max Weight must be positive'),
+ ]
+
+ @api.onchange('package_carrier_type')
+ def _onchange_carrier_type(self):
+ carrier_id = self.env['delivery.carrier'].search([('delivery_type', '=', self.package_carrier_type)], limit=1)
+ if carrier_id:
+ self.shipper_package_code = carrier_id._get_default_custom_package_code()
+ else:
+ self.shipper_package_code = False
+
+
+ def _compute_length_uom_name(self):
+ for packaging in self:
+ packaging.length_uom_name = self.env['product.template']._get_length_uom_name_from_ir_config_parameter()
+
+ def _compute_weight_uom_name(self):
+ for packaging in self:
+ packaging.weight_uom_name = self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
diff --git a/addons/delivery/models/product_template.py b/addons/delivery/models/product_template.py
new file mode 100644
index 00000000..eceb08e5
--- /dev/null
+++ b/addons/delivery/models/product_template.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models, fields
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ hs_code = fields.Char(
+ string="HS Code",
+ help="Standardized code for international shipping and goods declaration. At the moment, only used for the FedEx shipping provider.",
+ )
diff --git a/addons/delivery/models/sale_order.py b/addons/delivery/models/sale_order.py
new file mode 100644
index 00000000..89b8bdcf
--- /dev/null
+++ b/addons/delivery/models/sale_order.py
@@ -0,0 +1,189 @@
+# -*- 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
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ carrier_id = fields.Many2one('delivery.carrier', string="Delivery Method", domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", help="Fill this field if you plan to invoice the shipping based on picking.")
+ delivery_message = fields.Char(readonly=True, copy=False)
+ delivery_rating_success = fields.Boolean(copy=False)
+ delivery_set = fields.Boolean(compute='_compute_delivery_state')
+ recompute_delivery_price = fields.Boolean('Delivery cost should be recomputed')
+ is_all_service = fields.Boolean("Service Product", compute="_compute_is_service_products")
+
+ @api.depends('order_line')
+ def _compute_is_service_products(self):
+ for so in self:
+ so.is_all_service = all(line.product_id.type == 'service' for line in so.order_line)
+
+ def _compute_amount_total_without_delivery(self):
+ self.ensure_one()
+ delivery_cost = sum([l.price_total for l in self.order_line if l.is_delivery])
+ return self.amount_total - delivery_cost
+
+ @api.depends('order_line')
+ def _compute_delivery_state(self):
+ for order in self:
+ order.delivery_set = any(line.is_delivery for line in order.order_line)
+
+ @api.onchange('order_line', 'partner_id')
+ def onchange_order_line(self):
+ delivery_line = self.order_line.filtered('is_delivery')
+ if delivery_line:
+ self.recompute_delivery_price = True
+
+ def _remove_delivery_line(self):
+ delivery_lines = self.env['sale.order.line'].search([('order_id', 'in', self.ids), ('is_delivery', '=', True)])
+ if not delivery_lines:
+ return
+ to_delete = delivery_lines.filtered(lambda x: x.qty_invoiced == 0)
+ if not to_delete:
+ raise UserError(
+ _('You can not update the shipping costs on an order where it was already invoiced!\n\nThe following delivery lines (product, invoiced quantity and price) have already been processed:\n\n')
+ + '\n'.join(['- %s: %s x %s' % (line.product_id.with_context(display_default_code=False).display_name, line.qty_invoiced, line.price_unit) for line in delivery_lines])
+ )
+ to_delete.unlink()
+
+ def set_delivery_line(self, carrier, amount):
+
+ # Remove delivery products from the sales order
+ self._remove_delivery_line()
+
+ for order in self:
+ order.carrier_id = carrier.id
+ order._create_delivery_line(carrier, amount)
+ return True
+
+ def action_open_delivery_wizard(self):
+ view_id = self.env.ref('delivery.choose_delivery_carrier_view_form').id
+ if self.env.context.get('carrier_recompute'):
+ name = _('Update shipping cost')
+ carrier = self.carrier_id
+ else:
+ name = _('Add a shipping method')
+ carrier = (
+ self.with_company(self.company_id).partner_shipping_id.property_delivery_carrier_id
+ or self.with_company(self.company_id).partner_shipping_id.commercial_partner_id.property_delivery_carrier_id
+ )
+ return {
+ 'name': name,
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'choose.delivery.carrier',
+ 'view_id': view_id,
+ 'views': [(view_id, 'form')],
+ 'target': 'new',
+ 'context': {
+ 'default_order_id': self.id,
+ 'default_carrier_id': carrier.id,
+ }
+ }
+
+ def _create_delivery_line(self, carrier, price_unit):
+ SaleOrderLine = self.env['sale.order.line']
+ if self.partner_id:
+ # set delivery detail in the customer language
+ carrier = carrier.with_context(lang=self.partner_id.lang)
+
+ # Apply fiscal position
+ taxes = carrier.product_id.taxes_id.filtered(lambda t: t.company_id.id == self.company_id.id)
+ taxes_ids = taxes.ids
+ if self.partner_id and self.fiscal_position_id:
+ taxes_ids = self.fiscal_position_id.map_tax(taxes, carrier.product_id, self.partner_id).ids
+
+ # Create the sales order line
+ carrier_with_partner_lang = carrier.with_context(lang=self.partner_id.lang)
+ if carrier_with_partner_lang.product_id.description_sale:
+ so_description = '%s: %s' % (carrier_with_partner_lang.name,
+ carrier_with_partner_lang.product_id.description_sale)
+ else:
+ so_description = carrier_with_partner_lang.name
+ values = {
+ 'order_id': self.id,
+ 'name': so_description,
+ 'product_uom_qty': 1,
+ 'product_uom': carrier.product_id.uom_id.id,
+ 'product_id': carrier.product_id.id,
+ 'tax_id': [(6, 0, taxes_ids)],
+ 'is_delivery': True,
+ }
+ if carrier.invoice_policy == 'real':
+ values['price_unit'] = 0
+ values['name'] += _(' (Estimated Cost: %s )', self._format_currency_amount(price_unit))
+ else:
+ values['price_unit'] = price_unit
+ if carrier.free_over and self.currency_id.is_zero(price_unit) :
+ values['name'] += '\n' + 'Free Shipping'
+ if self.order_line:
+ values['sequence'] = self.order_line[-1].sequence + 1
+ sol = SaleOrderLine.sudo().create(values)
+ return sol
+
+ def _format_currency_amount(self, amount):
+ pre = post = u''
+ if self.currency_id.position == 'before':
+ pre = u'{symbol}\N{NO-BREAK SPACE}'.format(symbol=self.currency_id.symbol or '')
+ else:
+ post = u'\N{NO-BREAK SPACE}{symbol}'.format(symbol=self.currency_id.symbol or '')
+ return u' {pre}{0}{post}'.format(amount, pre=pre, post=post)
+
+ @api.depends('order_line.is_delivery', 'order_line.is_downpayment')
+ def _get_invoice_status(self):
+ super()._get_invoice_status()
+ for order in self:
+ if order.invoice_status in ['no', 'invoiced']:
+ continue
+ order_lines = order.order_line.filtered(lambda x: not x.is_delivery and not x.is_downpayment and not x.display_type)
+ if all(line.product_id.invoice_policy == 'delivery' and line.invoice_status == 'no' for line in order_lines):
+ order.invoice_status = 'no'
+
+ def _get_estimated_weight(self):
+ self.ensure_one()
+ 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
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ is_delivery = fields.Boolean(string="Is a Delivery", default=False)
+ product_qty = fields.Float(compute='_compute_product_qty', string='Product Qty', digits='Product Unit of Measure')
+ recompute_delivery_price = fields.Boolean(related='order_id.recompute_delivery_price')
+
+ @api.depends('product_id', 'product_uom', 'product_uom_qty')
+ def _compute_product_qty(self):
+ for line in self:
+ if not line.product_id or not line.product_uom or not line.product_uom_qty:
+ line.product_qty = 0.0
+ continue
+ line.product_qty = line.product_uom._compute_quantity(line.product_uom_qty, line.product_id.uom_id)
+
+ def unlink(self):
+ for line in self:
+ if line.is_delivery:
+ line.order_id.carrier_id = False
+ super(SaleOrderLine, self).unlink()
+
+ def _is_delivery(self):
+ self.ensure_one()
+ return self.is_delivery
+
+ # override to allow deletion of delivery line in a confirmed order
+ def _check_line_unlink(self):
+ """
+ Extend the allowed deletion policy of SO lines.
+
+ Lines that are delivery lines can be deleted from a confirmed order.
+
+ :rtype: recordset sale.order.line
+ :returns: set of lines that cannot be deleted
+ """
+
+ undeletable_lines = super()._check_line_unlink()
+ return undeletable_lines.filtered(lambda line: not line.is_delivery)
diff --git a/addons/delivery/models/stock_move.py b/addons/delivery/models/stock_move.py
new file mode 100644
index 00000000..468e63c1
--- /dev/null
+++ b/addons/delivery/models/stock_move.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+
+class StockMove(models.Model):
+ _inherit = 'stock.move'
+
+ weight = fields.Float(compute='_cal_move_weight', digits='Stock Weight', store=True, compute_sudo=True)
+
+ @api.depends('product_id', 'product_uom_qty', 'product_uom')
+ def _cal_move_weight(self):
+ moves_with_weight = self.filtered(lambda moves: moves.product_id.weight > 0.00)
+ for move in moves_with_weight:
+ move.weight = (move.product_qty * move.product_id.weight)
+ (self - moves_with_weight).weight = 0
+
+ def _get_new_picking_values(self):
+ vals = super(StockMove, self)._get_new_picking_values()
+ vals['carrier_id'] = self.mapped('sale_line_id.order_id.carrier_id').id
+ return vals
+
+ def _key_assign_picking(self):
+ keys = super(StockMove, self)._key_assign_picking()
+ return keys + (self.sale_line_id.order_id.carrier_id,)
+
+class StockMoveLine(models.Model):
+ _inherit = 'stock.move.line'
+
+ sale_price = fields.Float(compute='_compute_sale_price')
+
+ @api.depends('qty_done', 'product_uom_id', 'product_id', 'move_id.sale_line_id', 'move_id.sale_line_id.price_reduce_taxinc', 'move_id.sale_line_id.product_uom')
+ def _compute_sale_price(self):
+ for move_line in self:
+ if move_line.move_id.sale_line_id:
+ unit_price = move_line.move_id.sale_line_id.price_reduce_taxinc
+ qty = move_line.product_uom_id._compute_quantity(move_line.move_id.sale_line_id.product_qty, move_line.move_id.sale_line_id.product_uom)
+ else:
+ unit_price = move_line.product_id.list_price
+ qty = move_line.product_uom_id._compute_quantity(move_line.qty_done, move_line.product_id.uom_id)
+ move_line.sale_price = unit_price * qty
+
+ def _get_aggregated_product_quantities(self, **kwargs):
+ """Returns dictionary of products and corresponding values of interest + hs_code
+
+ Unfortunately because we are working with aggregated data, we have to loop through the
+ aggregation to add more values to each datum. This extension adds on the hs_code value.
+
+ returns: dictionary {same_key_as_super: {same_values_as_super, hs_code}, ...}
+ """
+ aggregated_move_lines = super()._get_aggregated_product_quantities(**kwargs)
+ for aggregated_move_line in aggregated_move_lines:
+ hs_code = aggregated_move_lines[aggregated_move_line]['product'].product_tmpl_id.hs_code
+ aggregated_move_lines[aggregated_move_line]['hs_code'] = hs_code
+ return aggregated_move_lines
diff --git a/addons/delivery/models/stock_picking.py b/addons/delivery/models/stock_picking.py
new file mode 100644
index 00000000..d58c2cf6
--- /dev/null
+++ b/addons/delivery/models/stock_picking.py
@@ -0,0 +1,262 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+
+from odoo import models, fields, api, _
+from odoo.exceptions import UserError
+
+
+
+class StockQuantPackage(models.Model):
+ _inherit = "stock.quant.package"
+
+ @api.depends('quant_ids')
+ def _compute_weight(self):
+ for package in self:
+ weight = 0.0
+ if self.env.context.get('picking_id'):
+ # TODO: potential bottleneck: N packages = N queries, use groupby ?
+ current_picking_move_line_ids = self.env['stock.move.line'].search([
+ ('result_package_id', '=', package.id),
+ ('picking_id', '=', self.env.context['picking_id'])
+ ])
+ for ml in current_picking_move_line_ids:
+ weight += ml.product_uom_id._compute_quantity(
+ ml.qty_done, ml.product_id.uom_id) * ml.product_id.weight
+ else:
+ for quant in package.quant_ids:
+ weight += quant.quantity * quant.product_id.weight
+ package.weight = weight
+
+ def _get_default_weight_uom(self):
+ return self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
+
+ def _compute_weight_uom_name(self):
+ for package in self:
+ package.weight_uom_name = self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
+
+ weight = fields.Float(compute='_compute_weight', help="Total weight of all the products contained in the package.")
+ weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name', readonly=True, default=_get_default_weight_uom)
+ shipping_weight = fields.Float(string='Shipping Weight', help="Total weight of the package.")
+
+
+class StockPicking(models.Model):
+ _inherit = 'stock.picking'
+
+
+ @api.depends('move_line_ids', 'move_line_ids.result_package_id')
+ def _compute_packages(self):
+ for package in self:
+ packs = set()
+ for move_line in package.move_line_ids:
+ if move_line.result_package_id:
+ packs.add(move_line.result_package_id.id)
+ package.package_ids = list(packs)
+
+ @api.depends('move_line_ids', 'move_line_ids.result_package_id', 'move_line_ids.product_uom_id', 'move_line_ids.qty_done')
+ def _compute_bulk_weight(self):
+ for picking in self:
+ weight = 0.0
+ for move_line in picking.move_line_ids:
+ if move_line.product_id and not move_line.result_package_id:
+ weight += move_line.product_uom_id._compute_quantity(move_line.qty_done, move_line.product_id.uom_id) * move_line.product_id.weight
+ picking.weight_bulk = weight
+
+ @api.depends('move_line_ids.result_package_id', 'move_line_ids.result_package_id.shipping_weight', 'weight_bulk')
+ def _compute_shipping_weight(self):
+ for picking in self:
+ # if shipping weight is not assigned => default to calculated product weight
+ picking.shipping_weight = picking.weight_bulk + sum([pack.shipping_weight or pack.weight for pack in picking.package_ids])
+
+ def _get_default_weight_uom(self):
+ return self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
+
+ def _compute_weight_uom_name(self):
+ for package in self:
+ package.weight_uom_name = self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
+
+ carrier_price = fields.Float(string="Shipping Cost")
+ delivery_type = fields.Selection(related='carrier_id.delivery_type', readonly=True)
+ carrier_id = fields.Many2one("delivery.carrier", string="Carrier", check_company=True)
+ weight = fields.Float(compute='_cal_weight', digits='Stock Weight', store=True, help="Total weight of the products in the picking.", compute_sudo=True)
+ carrier_tracking_ref = fields.Char(string='Tracking Reference', copy=False)
+ carrier_tracking_url = fields.Char(string='Tracking URL', compute='_compute_carrier_tracking_url')
+ weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name', readonly=True, default=_get_default_weight_uom)
+ package_ids = fields.Many2many('stock.quant.package', compute='_compute_packages', string='Packages')
+ weight_bulk = fields.Float('Bulk Weight', compute='_compute_bulk_weight', help="Total weight of products which are not in a package.")
+ shipping_weight = fields.Float("Weight for Shipping", compute='_compute_shipping_weight',
+ help="Total weight of packages and products not in a package. Packages with no shipping weight specified will default to their products' total weight. This is the weight used to compute the cost of the shipping.")
+ is_return_picking = fields.Boolean(compute='_compute_return_picking')
+ return_label_ids = fields.One2many('ir.attachment', compute='_compute_return_label')
+
+ @api.depends('carrier_id', 'carrier_tracking_ref')
+ def _compute_carrier_tracking_url(self):
+ for picking in self:
+ picking.carrier_tracking_url = picking.carrier_id.get_tracking_link(picking) if picking.carrier_id and picking.carrier_tracking_ref else False
+
+ @api.depends('carrier_id', 'move_ids_without_package')
+ def _compute_return_picking(self):
+ for picking in self:
+ if picking.carrier_id and picking.carrier_id.can_generate_return:
+ picking.is_return_picking = any(m.origin_returned_move_id for m in picking.move_ids_without_package)
+ else:
+ picking.is_return_picking = False
+
+ def _compute_return_label(self):
+ for picking in self:
+ if picking.carrier_id:
+ picking.return_label_ids = self.env['ir.attachment'].search([('res_model', '=', 'stock.picking'), ('res_id', '=', picking.id), ('name', 'like', '%s%%' % picking.carrier_id.get_return_label_prefix())])
+ else:
+ picking.return_label_ids = False
+
+ def get_multiple_carrier_tracking(self):
+ self.ensure_one()
+ try:
+ return json.loads(self.carrier_tracking_url)
+ except (ValueError, TypeError):
+ return False
+
+ @api.depends('move_lines')
+ def _cal_weight(self):
+ for picking in self:
+ picking.weight = sum(move.weight for move in picking.move_lines if move.state != 'cancel')
+
+ def _send_confirmation_email(self):
+ for pick in self:
+ if pick.carrier_id:
+ if pick.carrier_id.integration_level == 'rate_and_ship' and pick.picking_type_code != 'incoming':
+ pick.send_to_shipper()
+ pick._check_carrier_details_compliance()
+ return super(StockPicking, self)._send_confirmation_email()
+
+ def _pre_put_in_pack_hook(self, move_line_ids):
+ res = super(StockPicking, self)._pre_put_in_pack_hook(move_line_ids)
+ if not res:
+ if self.carrier_id:
+ return self._set_delivery_packaging()
+ else:
+ return res
+
+ def _set_delivery_packaging(self):
+ """ This method returns an action allowing to set the product packaging and the shipping weight
+ on the stock.quant.package.
+ """
+ self.ensure_one()
+ view_id = self.env.ref('delivery.choose_delivery_package_view_form').id
+ context = dict(
+ self.env.context,
+ current_package_carrier_type=self.carrier_id.delivery_type,
+ default_picking_id=self.id
+ )
+ # As we pass the `delivery_type` ('fixed' or 'base_on_rule' by default) in a key who
+ # correspond to the `package_carrier_type` ('none' to default), we make a conversion.
+ # No need conversion for other carriers as the `delivery_type` and
+ #`package_carrier_type` will be the same in these cases.
+ if context['current_package_carrier_type'] in ['fixed', 'base_on_rule']:
+ context['current_package_carrier_type'] = 'none'
+ return {
+ 'name': _('Package Details'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'choose.delivery.package',
+ 'view_id': view_id,
+ 'views': [(view_id, 'form')],
+ 'target': 'new',
+ 'context': context,
+ }
+
+ def send_to_shipper(self):
+ self.ensure_one()
+ res = self.carrier_id.send_shipping(self)[0]
+ if self.carrier_id.free_over and self.sale_id and self.sale_id._compute_amount_total_without_delivery() >= self.carrier_id.amount:
+ res['exact_price'] = 0.0
+ self.carrier_price = res['exact_price'] * (1.0 + (self.carrier_id.margin / 100.0))
+ if res['tracking_number']:
+ self.carrier_tracking_ref = res['tracking_number']
+ order_currency = self.sale_id.currency_id or self.company_id.currency_id
+ msg = _(
+ "Shipment sent to carrier %(carrier_name)s for shipping with tracking number %(ref)s<br/>Cost: %(price).2f %(currency)s",
+ carrier_name=self.carrier_id.name,
+ ref=self.carrier_tracking_ref,
+ price=self.carrier_price,
+ currency=order_currency.name
+ )
+ self.message_post(body=msg)
+ self._add_delivery_cost_to_so()
+
+ def _check_carrier_details_compliance(self):
+ """Hook to check if a delivery is compliant in regard of the carrier.
+ """
+ pass
+
+ def print_return_label(self):
+ self.ensure_one()
+ self.carrier_id.get_return_label(self)
+
+ def _add_delivery_cost_to_so(self):
+ self.ensure_one()
+ sale_order = self.sale_id
+ if sale_order and self.carrier_id.invoice_policy == 'real' and self.carrier_price:
+ delivery_lines = sale_order.order_line.filtered(lambda l: l.is_delivery and l.currency_id.is_zero(l.price_unit) and l.product_id == self.carrier_id.product_id)
+ carrier_price = self.carrier_price * (1.0 + (float(self.carrier_id.margin) / 100.0))
+ if not delivery_lines:
+ delivery_lines = [sale_order._create_delivery_line(self.carrier_id, carrier_price)]
+ delivery_line = delivery_lines[0]
+ delivery_line[0].write({
+ 'price_unit': carrier_price,
+ # remove the estimated price from the description
+ 'name': sale_order.carrier_id.with_context(lang=self.partner_id.lang).name,
+ })
+
+ def open_website_url(self):
+ self.ensure_one()
+ if not self.carrier_tracking_url:
+ raise UserError(_("Your delivery method has no redirect on courier provider's website to track this order."))
+
+ carrier_trackers = []
+ try:
+ carrier_trackers = json.loads(self.carrier_tracking_url)
+ except ValueError:
+ carrier_trackers = self.carrier_tracking_url
+ else:
+ msg = "Tracking links for shipment: <br/>"
+ for tracker in carrier_trackers:
+ msg += '<a href=' + tracker[1] + '>' + tracker[0] + '</a><br/>'
+ self.message_post(body=msg)
+ return self.env["ir.actions.actions"]._for_xml_id("delivery.act_delivery_trackers_url")
+
+ client_action = {
+ 'type': 'ir.actions.act_url',
+ 'name': "Shipment Tracking Page",
+ 'target': 'new',
+ 'url': self.carrier_tracking_url,
+ }
+ return client_action
+
+ def cancel_shipment(self):
+ for picking in self:
+ picking.carrier_id.cancel_shipment(self)
+ msg = "Shipment %s cancelled" % picking.carrier_tracking_ref
+ picking.message_post(body=msg)
+ picking.carrier_tracking_ref = False
+
+ def _get_estimated_weight(self):
+ self.ensure_one()
+ weight = 0.0
+ for move in self.move_lines:
+ weight += move.product_qty * move.product_id.weight
+ return weight
+
+
+class StockReturnPicking(models.TransientModel):
+ _inherit = 'stock.return.picking'
+
+ def _create_returns(self):
+ # Prevent copy of the carrier and carrier price when generating return picking
+ # (we have no integration of returns for now)
+ new_picking, pick_type_id = super(StockReturnPicking, self)._create_returns()
+ picking = self.env['stock.picking'].browse(new_picking)
+ picking.write({'carrier_id': False,
+ 'carrier_price': 0.0})
+ return new_picking, pick_type_id