diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/delivery/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/delivery/models')
| -rw-r--r-- | addons/delivery/models/__init__.py | 11 | ||||
| -rw-r--r-- | addons/delivery/models/delivery_carrier.py | 279 | ||||
| -rw-r--r-- | addons/delivery/models/delivery_grid.py | 138 | ||||
| -rw-r--r-- | addons/delivery/models/partner.py | 10 | ||||
| -rw-r--r-- | addons/delivery/models/product_packaging.py | 48 | ||||
| -rw-r--r-- | addons/delivery/models/product_template.py | 13 | ||||
| -rw-r--r-- | addons/delivery/models/sale_order.py | 189 | ||||
| -rw-r--r-- | addons/delivery/models/stock_move.py | 57 | ||||
| -rw-r--r-- | addons/delivery/models/stock_picking.py | 262 |
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 |
