diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py index 92e9953d654..84b111f1fae 100644 --- a/addons/sale/models/__init__.py +++ b/addons/sale/models/__init__.py @@ -2,15 +2,16 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import analytic -from . import account_invoice from . import account_move +from . import account_move_line +from . import payment_acquirer +from . import payment_transaction from . import product_product from . import product_template from . import res_company -from . import res_partner -from . import sale from . import res_config_settings +from . import res_partner +from . import sale_order +from . import sale_order_line from . import sales_team -from . import payment -from . import utm -from . import mail_compose_message +from . import utm_campaign diff --git a/addons/sale/models/account_invoice.py b/addons/sale/models/account_invoice.py deleted file mode 100644 index 9ffe13e56d9..00000000000 --- a/addons/sale/models/account_invoice.py +++ /dev/null @@ -1,105 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class AccountMove(models.Model): - _name = 'account.move' - _inherit = ['account.move', 'utm.mixin'] - - @api.model - def _get_invoice_default_sale_team(self): - return self.env['crm.team']._get_default_team_id() - - team_id = fields.Many2one( - 'crm.team', string='Sales Team', default=_get_invoice_default_sale_team, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") - partner_shipping_id = fields.Many2one( - 'res.partner', - string='Delivery Address', - readonly=True, - states={'draft': [('readonly', False)]}, - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", - help="Delivery address for current invoice.") - - @api.onchange('partner_shipping_id', 'company_id') - def _onchange_partner_shipping_id(self): - """ - Trigger the change of fiscal position when the shipping address is modified. - """ - delivery_partner_id = self._get_invoice_delivery_partner_id() - fiscal_position = self.env['account.fiscal.position'].with_company(self.company_id).get_fiscal_position( - self.partner_id.id, delivery_id=delivery_partner_id) - - if fiscal_position: - self.fiscal_position_id = fiscal_position - - def unlink(self): - downpayment_lines = self.mapped('line_ids.sale_line_ids').filtered(lambda line: line.is_downpayment and line.invoice_lines <= self.mapped('line_ids')) - res = super(AccountMove, self).unlink() - if downpayment_lines: - downpayment_lines.unlink() - return res - - @api.onchange('partner_id') - def _onchange_partner_id(self): - # OVERRIDE - # Recompute 'partner_shipping_id' based on 'partner_id'. - addr = self.partner_id.address_get(['delivery']) - self.partner_shipping_id = addr and addr.get('delivery') - - res = super(AccountMove, self)._onchange_partner_id() - - # Recompute 'narration' based on 'company.invoice_terms'. - if self.move_type == 'out_invoice': - self.narration = self.company_id.with_context(lang=self.partner_id.lang or self.env.lang).invoice_terms - - return res - - @api.onchange('invoice_user_id') - def onchange_user_id(self): - if self.invoice_user_id and self.invoice_user_id.sale_team_id: - self.team_id = self.env['crm.team']._get_default_team_id(user_id=self.invoice_user_id.id, domain=[('company_id', '=', self.company_id.id)]) - - def _reverse_moves(self, default_values_list=None, cancel=False): - # OVERRIDE - if not default_values_list: - default_values_list = [{} for move in self] - for move, default_values in zip(self, default_values_list): - default_values.update({ - 'campaign_id': move.campaign_id.id, - 'medium_id': move.medium_id.id, - 'source_id': move.source_id.id, - }) - return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel) - - def _post(self, soft=True): - # OVERRIDE - # Auto-reconcile the invoice with payments coming from transactions. - # It's useful when you have a "paid" sale order (using a payment transaction) and you invoice it later. - posted = super()._post(soft) - - for invoice in posted.filtered(lambda move: move.is_invoice()): - payments = invoice.mapped('transaction_ids.payment_id') - move_lines = payments.line_ids.filtered(lambda line: line.account_internal_type in ('receivable', 'payable') and not line.reconciled) - for line in move_lines: - invoice.js_assign_outstanding_line(line.id) - return posted - - def action_invoice_paid(self): - # OVERRIDE - res = super(AccountMove, self).action_invoice_paid() - todo = set() - for invoice in self.filtered(lambda move: move.is_invoice()): - for line in invoice.invoice_line_ids: - for sale_line in line.sale_line_ids: - todo.add((sale_line.order_id, invoice.name)) - for (order, name) in todo: - order.message_post(body=_("Invoice %s paid", name)) - return res - - def _get_invoice_delivery_partner_id(self): - # OVERRIDE - self.ensure_one() - return self.partner_shipping_id.id or super(AccountMove, self)._get_invoice_delivery_partner_id() diff --git a/addons/sale/models/account_move.py b/addons/sale/models/account_move.py index caae347ee13..e5e9e84379d 100644 --- a/addons/sale/models/account_move.py +++ b/addons/sale/models/account_move.py @@ -3,11 +3,76 @@ from odoo import api, fields, models, _ from odoo.exceptions import UserError -from odoo.tools import float_compare, float_is_zero - class AccountMove(models.Model): - _inherit = 'account.move' + _name = 'account.move' + _inherit = ['account.move', 'utm.mixin'] + + @api.model + def _get_invoice_default_sale_team(self): + return self.env['crm.team']._get_default_team_id() + + team_id = fields.Many2one( + 'crm.team', string='Sales Team', default=_get_invoice_default_sale_team, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + partner_shipping_id = fields.Many2one( + 'res.partner', + string='Delivery Address', + readonly=True, + states={'draft': [('readonly', False)]}, + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", + help="Delivery address for current invoice.") + + @api.onchange('partner_shipping_id', 'company_id') + def _onchange_partner_shipping_id(self): + """ + Trigger the change of fiscal position when the shipping address is modified. + """ + delivery_partner_id = self._get_invoice_delivery_partner_id() + fiscal_position = self.env['account.fiscal.position'].with_company(self.company_id).get_fiscal_position( + self.partner_id.id, delivery_id=delivery_partner_id) + + if fiscal_position: + self.fiscal_position_id = fiscal_position + + def unlink(self): + downpayment_lines = self.mapped('line_ids.sale_line_ids').filtered(lambda line: line.is_downpayment and line.invoice_lines <= self.mapped('line_ids')) + res = super(AccountMove, self).unlink() + if downpayment_lines: + downpayment_lines.unlink() + return res + + @api.onchange('partner_id') + def _onchange_partner_id(self): + # OVERRIDE + # Recompute 'partner_shipping_id' based on 'partner_id'. + addr = self.partner_id.address_get(['delivery']) + self.partner_shipping_id = addr and addr.get('delivery') + + res = super(AccountMove, self)._onchange_partner_id() + + # Recompute 'narration' based on 'company.invoice_terms'. + if self.move_type == 'out_invoice': + self.narration = self.company_id.with_context(lang=self.partner_id.lang or self.env.lang).invoice_terms + + return res + + @api.onchange('invoice_user_id') + def onchange_user_id(self): + if self.invoice_user_id and self.invoice_user_id.sale_team_id: + self.team_id = self.env['crm.team']._get_default_team_id(user_id=self.invoice_user_id.id, domain=[('company_id', '=', self.company_id.id)]) + + def _reverse_moves(self, default_values_list=None, cancel=False): + # OVERRIDE + if not default_values_list: + default_values_list = [{} for move in self] + for move, default_values in zip(self, default_values_list): + default_values.update({ + 'campaign_id': move.campaign_id.id, + 'medium_id': move.medium_id.id, + 'source_id': move.source_id.id, + }) + return super()._reverse_moves(default_values_list=default_values_list, cancel=cancel) def action_post(self): #inherit of the function from account.move to validate a new tax and the priceunit of a downpayment @@ -28,201 +93,32 @@ class AccountMove(models.Model): pass return res -class AccountMoveLine(models.Model): - _inherit = 'account.move.line' + def _post(self, soft=True): + # OVERRIDE + # Auto-reconcile the invoice with payments coming from transactions. + # It's useful when you have a "paid" sale order (using a payment transaction) and you invoice it later. + posted = super()._post(soft) - sale_line_ids = fields.Many2many( - 'sale.order.line', - 'sale_order_line_invoice_rel', - 'invoice_line_id', 'order_line_id', - string='Sales Order Lines', readonly=True, copy=False) + for invoice in posted.filtered(lambda move: move.is_invoice()): + payments = invoice.mapped('transaction_ids.payment_id') + move_lines = payments.line_ids.filtered(lambda line: line.account_internal_type in ('receivable', 'payable') and not line.reconciled) + for line in move_lines: + invoice.js_assign_outstanding_line(line.id) + return posted - def _copy_data_extend_business_fields(self, values): - # OVERRIDE to copy the 'sale_line_ids' field as well. - super(AccountMoveLine, self)._copy_data_extend_business_fields(values) - values['sale_line_ids'] = [(6, None, self.sale_line_ids.ids)] + def action_invoice_paid(self): + # OVERRIDE + res = super(AccountMove, self).action_invoice_paid() + todo = set() + for invoice in self.filtered(lambda move: move.is_invoice()): + for line in invoice.invoice_line_ids: + for sale_line in line.sale_line_ids: + todo.add((sale_line.order_id, invoice.name)) + for (order, name) in todo: + order.message_post(body=_("Invoice %s paid", name)) + return res - def _prepare_analytic_line(self): - """ Note: This method is called only on the move.line that having an analytic account, and - so that should create analytic entries. - """ - values_list = super(AccountMoveLine, self)._prepare_analytic_line() - - # filter the move lines that can be reinvoiced: a cost (negative amount) analytic line without SO line but with a product can be reinvoiced - move_to_reinvoice = self.env['account.move.line'] - for index, move_line in enumerate(self): - values = values_list[index] - if 'so_line' not in values: - if move_line._sale_can_be_reinvoice(): - move_to_reinvoice |= move_line - - # insert the sale line in the create values of the analytic entries - if move_to_reinvoice: - map_sale_line_per_move = move_to_reinvoice._sale_create_reinvoice_sale_line() - - for values in values_list: - sale_line = map_sale_line_per_move.get(values.get('move_id')) - if sale_line: - values['so_line'] = sale_line.id - - return values_list - - def _sale_can_be_reinvoice(self): - """ determine if the generated analytic line should be reinvoiced or not. - For Vendor Bill flow, if the product has a 'erinvoice policy' and is a cost, then we will find the SO on which reinvoice the AAL - """ + def _get_invoice_delivery_partner_id(self): + # OVERRIDE self.ensure_one() - if self.sale_line_ids: - return False - uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') - return float_compare(self.credit or 0.0, self.debit or 0.0, precision_digits=uom_precision_digits) != 1 and self.product_id.expense_policy not in [False, 'no'] - - def _sale_create_reinvoice_sale_line(self): - - sale_order_map = self._sale_determine_order() - - sale_line_values_to_create = [] # the list of creation values of sale line to create. - existing_sale_line_cache = {} # in the sales_price-delivery case, we can reuse the same sale line. This cache will avoid doing a search each time the case happen - # `map_move_sale_line` is map where - # - key is the move line identifier - # - value is either a sale.order.line record (existing case), or an integer representing the index of the sale line to create in - # the `sale_line_values_to_create` (not existing case, which will happen more often than the first one). - map_move_sale_line = {} - - for move_line in self: - sale_order = sale_order_map.get(move_line.id) - - # no reinvoice as no sales order was found - if not sale_order: - continue - - # raise if the sale order is not currenlty open - if sale_order.state != 'sale': - message_unconfirmed = _('The Sales Order %s linked to the Analytic Account %s must be validated before registering expenses.') - messages = { - 'draft': message_unconfirmed, - 'sent': message_unconfirmed, - 'done': _('The Sales Order %s linked to the Analytic Account %s is currently locked. You cannot register an expense on a locked Sales Order. Please create a new SO linked to this Analytic Account.'), - 'cancel': _('The Sales Order %s linked to the Analytic Account %s is cancelled. You cannot register an expense on a cancelled Sales Order.'), - } - raise UserError(messages[sale_order.state] % (sale_order.name, sale_order.analytic_account_id.name)) - - price = move_line._sale_get_invoice_price(sale_order) - - # find the existing sale.line or keep its creation values to process this in batch - sale_line = None - if move_line.product_id.expense_policy == 'sales_price' and move_line.product_id.invoice_policy == 'delivery': # for those case only, we can try to reuse one - map_entry_key = (sale_order.id, move_line.product_id.id, price) # cache entry to limit the call to search - sale_line = existing_sale_line_cache.get(map_entry_key) - if sale_line: # already search, so reuse it. sale_line can be sale.order.line record or index of a "to create values" in `sale_line_values_to_create` - map_move_sale_line[move_line.id] = sale_line - existing_sale_line_cache[map_entry_key] = sale_line - else: # search for existing sale line - sale_line = self.env['sale.order.line'].search([ - ('order_id', '=', sale_order.id), - ('price_unit', '=', price), - ('product_id', '=', move_line.product_id.id), - ('is_expense', '=', True), - ], limit=1) - if sale_line: # found existing one, so keep the browse record - map_move_sale_line[move_line.id] = existing_sale_line_cache[map_entry_key] = sale_line - else: # should be create, so use the index of creation values instead of browse record - # save value to create it - sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price)) - # store it in the cache of existing ones - existing_sale_line_cache[map_entry_key] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line - # store it in the map_move_sale_line map - map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line - - else: # save its value to create it anyway - sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price)) - map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line - - # create the sale lines in batch - new_sale_lines = self.env['sale.order.line'].create(sale_line_values_to_create) - new_sale_lines._onchange_discount() - - # build result map by replacing index with newly created record of sale.order.line - result = {} - for move_line_id, unknown_sale_line in map_move_sale_line.items(): - if isinstance(unknown_sale_line, int): # index of newly created sale line - result[move_line_id] = new_sale_lines[unknown_sale_line] - elif isinstance(unknown_sale_line, models.BaseModel): # already record of sale.order.line - result[move_line_id] = unknown_sale_line - return result - - def _sale_determine_order(self): - """ Get the mapping of move.line with the sale.order record on which its analytic entries should be reinvoiced - :return a dict where key is the move line id, and value is sale.order record (or None). - """ - analytic_accounts = self.mapped('analytic_account_id') - - # link the analytic account with its open SO by creating a map: {AA.id: sale.order}, if we find some analytic accounts - mapping = {} - if analytic_accounts: # first, search for the open sales order - sale_orders = self.env['sale.order'].search([('analytic_account_id', 'in', analytic_accounts.ids), ('state', '=', 'sale')], order='create_date DESC') - for sale_order in sale_orders: - mapping[sale_order.analytic_account_id.id] = sale_order - - analytic_accounts_without_open_order = analytic_accounts.filtered(lambda account: not mapping.get(account.id)) - if analytic_accounts_without_open_order: # then, fill the blank with not open sales orders - sale_orders = self.env['sale.order'].search([('analytic_account_id', 'in', analytic_accounts_without_open_order.ids)], order='create_date DESC') - for sale_order in sale_orders: - mapping[sale_order.analytic_account_id.id] = sale_order - - # map of AAL index with the SO on which it needs to be reinvoiced. Maybe be None if no SO found - return {move_line.id: mapping.get(move_line.analytic_account_id.id) for move_line in self} - - def _sale_prepare_sale_line_values(self, order, price): - """ Generate the sale.line creation value from the current move line """ - self.ensure_one() - last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1) - last_sequence = last_so_line.sequence + 1 if last_so_line else 100 - - fpos = order.fiscal_position_id or order.fiscal_position_id.get_fiscal_position(order.partner_id.id) - taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id) - - return { - 'order_id': order.id, - 'name': self.name, - 'sequence': last_sequence, - 'price_unit': price, - 'tax_id': [x.id for x in taxes], - 'discount': 0.0, - 'product_id': self.product_id.id, - 'product_uom': self.product_uom_id.id, - 'product_uom_qty': 0.0, - 'is_expense': True, - } - - def _sale_get_invoice_price(self, order): - """ Based on the current move line, compute the price to reinvoice the analytic line that is going to be created (so the - price of the sale line). - """ - self.ensure_one() - - unit_amount = self.quantity - amount = (self.credit or 0.0) - (self.debit or 0.0) - - if self.product_id.expense_policy == 'sales_price': - return self.product_id.with_context( - partner=order.partner_id.id, - date_order=order.date_order, - pricelist=order.pricelist_id.id, - uom=self.product_uom_id.id - ).price - - uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') - if float_is_zero(unit_amount, precision_digits=uom_precision_digits): - return 0.0 - - # Prevent unnecessary currency conversion that could be impacted by exchange rate - # fluctuations - if self.company_id.currency_id and amount and self.company_id.currency_id == order.currency_id: - return abs(amount / unit_amount) - - price_unit = abs(amount / unit_amount) - currency_id = self.company_id.currency_id - if currency_id and currency_id != order.currency_id: - price_unit = currency_id._convert(price_unit, order.currency_id, order.company_id, order.date_order or fields.Date.today()) - return price_unit + return self.partner_shipping_id.id or super(AccountMove, self)._get_invoice_delivery_partner_id() diff --git a/addons/sale/models/account_move_line.py b/addons/sale/models/account_move_line.py new file mode 100644 index 00000000000..c24b4285ea4 --- /dev/null +++ b/addons/sale/models/account_move_line.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, _ +from odoo.exceptions import UserError +from odoo.tools import float_compare, float_is_zero + + +class AccountMoveLine(models.Model): + _inherit = 'account.move.line' + + sale_line_ids = fields.Many2many( + 'sale.order.line', + 'sale_order_line_invoice_rel', + 'invoice_line_id', 'order_line_id', + string='Sales Order Lines', readonly=True, copy=False) + + def _copy_data_extend_business_fields(self, values): + # OVERRIDE to copy the 'sale_line_ids' field as well. + super(AccountMoveLine, self)._copy_data_extend_business_fields(values) + values['sale_line_ids'] = [(6, None, self.sale_line_ids.ids)] + + def _prepare_analytic_line(self): + """ Note: This method is called only on the move.line that having an analytic account, and + so that should create analytic entries. + """ + values_list = super(AccountMoveLine, self)._prepare_analytic_line() + + # filter the move lines that can be reinvoiced: a cost (negative amount) analytic line without SO line but with a product can be reinvoiced + move_to_reinvoice = self.env['account.move.line'] + for index, move_line in enumerate(self): + values = values_list[index] + if 'so_line' not in values: + if move_line._sale_can_be_reinvoice(): + move_to_reinvoice |= move_line + + # insert the sale line in the create values of the analytic entries + if move_to_reinvoice: + map_sale_line_per_move = move_to_reinvoice._sale_create_reinvoice_sale_line() + + for values in values_list: + sale_line = map_sale_line_per_move.get(values.get('move_id')) + if sale_line: + values['so_line'] = sale_line.id + + return values_list + + def _sale_can_be_reinvoice(self): + """ determine if the generated analytic line should be reinvoiced or not. + For Vendor Bill flow, if the product has a 'erinvoice policy' and is a cost, then we will find the SO on which reinvoice the AAL + """ + self.ensure_one() + if self.sale_line_ids: + return False + uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') + return float_compare(self.credit or 0.0, self.debit or 0.0, precision_digits=uom_precision_digits) != 1 and self.product_id.expense_policy not in [False, 'no'] + + def _sale_create_reinvoice_sale_line(self): + + sale_order_map = self._sale_determine_order() + + sale_line_values_to_create = [] # the list of creation values of sale line to create. + existing_sale_line_cache = {} # in the sales_price-delivery case, we can reuse the same sale line. This cache will avoid doing a search each time the case happen + # `map_move_sale_line` is map where + # - key is the move line identifier + # - value is either a sale.order.line record (existing case), or an integer representing the index of the sale line to create in + # the `sale_line_values_to_create` (not existing case, which will happen more often than the first one). + map_move_sale_line = {} + + for move_line in self: + sale_order = sale_order_map.get(move_line.id) + + # no reinvoice as no sales order was found + if not sale_order: + continue + + # raise if the sale order is not currenlty open + if sale_order.state != 'sale': + message_unconfirmed = _('The Sales Order %s linked to the Analytic Account %s must be validated before registering expenses.') + messages = { + 'draft': message_unconfirmed, + 'sent': message_unconfirmed, + 'done': _('The Sales Order %s linked to the Analytic Account %s is currently locked. You cannot register an expense on a locked Sales Order. Please create a new SO linked to this Analytic Account.'), + 'cancel': _('The Sales Order %s linked to the Analytic Account %s is cancelled. You cannot register an expense on a cancelled Sales Order.'), + } + raise UserError(messages[sale_order.state] % (sale_order.name, sale_order.analytic_account_id.name)) + + price = move_line._sale_get_invoice_price(sale_order) + + # find the existing sale.line or keep its creation values to process this in batch + sale_line = None + if move_line.product_id.expense_policy == 'sales_price' and move_line.product_id.invoice_policy == 'delivery': # for those case only, we can try to reuse one + map_entry_key = (sale_order.id, move_line.product_id.id, price) # cache entry to limit the call to search + sale_line = existing_sale_line_cache.get(map_entry_key) + if sale_line: # already search, so reuse it. sale_line can be sale.order.line record or index of a "to create values" in `sale_line_values_to_create` + map_move_sale_line[move_line.id] = sale_line + existing_sale_line_cache[map_entry_key] = sale_line + else: # search for existing sale line + sale_line = self.env['sale.order.line'].search([ + ('order_id', '=', sale_order.id), + ('price_unit', '=', price), + ('product_id', '=', move_line.product_id.id), + ('is_expense', '=', True), + ], limit=1) + if sale_line: # found existing one, so keep the browse record + map_move_sale_line[move_line.id] = existing_sale_line_cache[map_entry_key] = sale_line + else: # should be create, so use the index of creation values instead of browse record + # save value to create it + sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price)) + # store it in the cache of existing ones + existing_sale_line_cache[map_entry_key] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line + # store it in the map_move_sale_line map + map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line + + else: # save its value to create it anyway + sale_line_values_to_create.append(move_line._sale_prepare_sale_line_values(sale_order, price)) + map_move_sale_line[move_line.id] = len(sale_line_values_to_create) - 1 # save the index of the value to create sale line + + # create the sale lines in batch + new_sale_lines = self.env['sale.order.line'].create(sale_line_values_to_create) + new_sale_lines._onchange_discount() + + # build result map by replacing index with newly created record of sale.order.line + result = {} + for move_line_id, unknown_sale_line in map_move_sale_line.items(): + if isinstance(unknown_sale_line, int): # index of newly created sale line + result[move_line_id] = new_sale_lines[unknown_sale_line] + elif isinstance(unknown_sale_line, models.BaseModel): # already record of sale.order.line + result[move_line_id] = unknown_sale_line + return result + + def _sale_determine_order(self): + """ Get the mapping of move.line with the sale.order record on which its analytic entries should be reinvoiced + :return a dict where key is the move line id, and value is sale.order record (or None). + """ + analytic_accounts = self.mapped('analytic_account_id') + + # link the analytic account with its open SO by creating a map: {AA.id: sale.order}, if we find some analytic accounts + mapping = {} + if analytic_accounts: # first, search for the open sales order + sale_orders = self.env['sale.order'].search([('analytic_account_id', 'in', analytic_accounts.ids), ('state', '=', 'sale')], order='create_date DESC') + for sale_order in sale_orders: + mapping[sale_order.analytic_account_id.id] = sale_order + + analytic_accounts_without_open_order = analytic_accounts.filtered(lambda account: not mapping.get(account.id)) + if analytic_accounts_without_open_order: # then, fill the blank with not open sales orders + sale_orders = self.env['sale.order'].search([('analytic_account_id', 'in', analytic_accounts_without_open_order.ids)], order='create_date DESC') + for sale_order in sale_orders: + mapping[sale_order.analytic_account_id.id] = sale_order + + # map of AAL index with the SO on which it needs to be reinvoiced. Maybe be None if no SO found + return {move_line.id: mapping.get(move_line.analytic_account_id.id) for move_line in self} + + def _sale_prepare_sale_line_values(self, order, price): + """ Generate the sale.line creation value from the current move line """ + self.ensure_one() + last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1) + last_sequence = last_so_line.sequence + 1 if last_so_line else 100 + + fpos = order.fiscal_position_id or order.fiscal_position_id.get_fiscal_position(order.partner_id.id) + taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id) + + return { + 'order_id': order.id, + 'name': self.name, + 'sequence': last_sequence, + 'price_unit': price, + 'tax_id': [x.id for x in taxes], + 'discount': 0.0, + 'product_id': self.product_id.id, + 'product_uom': self.product_uom_id.id, + 'product_uom_qty': 0.0, + 'is_expense': True, + } + + def _sale_get_invoice_price(self, order): + """ Based on the current move line, compute the price to reinvoice the analytic line that is going to be created (so the + price of the sale line). + """ + self.ensure_one() + + unit_amount = self.quantity + amount = (self.credit or 0.0) - (self.debit or 0.0) + + if self.product_id.expense_policy == 'sales_price': + return self.product_id.with_context( + partner=order.partner_id.id, + date_order=order.date_order, + pricelist=order.pricelist_id.id, + uom=self.product_uom_id.id + ).price + + uom_precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure') + if float_is_zero(unit_amount, precision_digits=uom_precision_digits): + return 0.0 + + # Prevent unnecessary currency conversion that could be impacted by exchange rate + # fluctuations + if self.company_id.currency_id and amount and self.company_id.currency_id == order.currency_id: + return abs(amount / unit_amount) + + price_unit = abs(amount / unit_amount) + currency_id = self.company_id.currency_id + if currency_id and currency_id != order.currency_id: + price_unit = currency_id._convert(price_unit, order.currency_id, order.company_id, order.date_order or fields.Date.today()) + return price_unit diff --git a/addons/sale/models/payment_acquirer.py b/addons/sale/models/payment_acquirer.py new file mode 100644 index 00000000000..e9cc1d9f2ca --- /dev/null +++ b/addons/sale/models/payment_acquirer.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + so_reference_type = fields.Selection(string='Communication', + selection=[ + ('so_name', 'Based on Document Reference'), + ('partner', 'Based on Customer ID')], default='so_name', + help='You can set here the communication type that will appear on sales orders.' + 'The communication will be given to the customer when they choose the payment method.') diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment_transaction.py similarity index 94% rename from addons/sale/models/payment.py rename to addons/sale/models/payment_transaction.py index 4537f1fc6f8..b960d32ee63 100644 --- a/addons/sale/models/payment.py +++ b/addons/sale/models/payment_transaction.py @@ -10,17 +10,6 @@ from odoo.tools import float_compare _logger = logging.getLogger(__name__) -class PaymentAcquirer(models.Model): - _inherit = 'payment.acquirer' - - so_reference_type = fields.Selection(string='Communication', - selection=[ - ('so_name', 'Based on Document Reference'), - ('partner', 'Based on Customer ID')], default='so_name', - help='You can set here the communication type that will appear on sales orders.' - 'The communication will be given to the customer when they choose the payment method.') - - class PaymentTransaction(models.Model): _inherit = 'payment.transaction' diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 1e0292ddf44..5b0a9e082fc 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import timedelta, time -from odoo import api, fields, models +from odoo import fields, models from odoo.tools.float_utils import float_round diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py index e5f70e2eb98..bb6492ef179 100644 --- a/addons/sale/models/res_company.py +++ b/addons/sale/models/res_company.py @@ -4,7 +4,7 @@ import base64 from odoo import api, fields, models, _ from odoo.modules.module import get_module_resource -from odoo.modules.module import get_resource_path + class ResCompany(models.Model): _inherit = "res.company" diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale_order.py similarity index 53% rename from addons/sale/models/sale.py rename to addons/sale/models/sale_order.py index 0aa6cf8e803..f11e1dfcaad 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale_order.py @@ -7,12 +7,9 @@ from itertools import groupby from odoo import api, fields, models, SUPERUSER_ID, _ from odoo.exceptions import AccessError, UserError, ValidationError -from odoo.tools.misc import formatLang, get_lang +from odoo.tools.misc import formatLang from odoo.osv import expression -from odoo.tools import float_is_zero, float_compare, float_round - - -from werkzeug.urls import url_encode +from odoo.tools import float_is_zero class SaleOrder(models.Model): @@ -1058,821 +1055,3 @@ Reason(s) of this behavior could be: def add_option_to_order_with_taxcloud(self): self.ensure_one() - - -class SaleOrderLine(models.Model): - _name = 'sale.order.line' - _description = 'Sales Order Line' - _order = 'order_id, sequence, id' - _check_company_auto = True - - @api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced') - def _compute_invoice_status(self): - """ - Compute the invoice status of a SO line. Possible statuses: - - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to - invoice. This is also hte default value if the conditions of no other status is met. - - to invoice: we refer to the quantity to invoice of the line. Refer to method - `_get_to_invoice_qty()` for more information on how this quantity is calculated. - - upselling: this is possible only for a product invoiced on ordered quantities for which - we delivered more than expected. The could arise if, for example, a project took more - time than expected but we decided not to invoice the extra cost to the client. This - occurs onyl in state 'sale', so that when a SO is set to done, the upselling opportunity - is removed from the list. - - invoiced: the quantity invoiced is larger or equal to the quantity ordered. - """ - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - for line in self: - if line.state not in ('sale', 'done'): - line.invoice_status = 'no' - elif line.is_downpayment and line.untaxed_amount_to_invoice == 0: - line.invoice_status = 'invoiced' - elif not float_is_zero(line.qty_to_invoice, precision_digits=precision): - line.invoice_status = 'to invoice' - elif line.state == 'sale' and line.product_id.invoice_policy == 'order' and\ - float_compare(line.qty_delivered, line.product_uom_qty, precision_digits=precision) == 1: - line.invoice_status = 'upselling' - elif float_compare(line.qty_invoiced, line.product_uom_qty, precision_digits=precision) >= 0: - line.invoice_status = 'invoiced' - else: - line.invoice_status = 'no' - - def _expected_date(self): - self.ensure_one() - order_date = fields.Datetime.from_string(self.order_id.date_order if self.order_id.date_order and self.order_id.state in ['sale', 'done'] else fields.Datetime.now()) - return order_date + timedelta(days=self.customer_lead or 0.0) - - @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') - def _compute_amount(self): - """ - Compute the amounts of the SO line. - """ - for line in self: - price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - taxes = line.tax_id.compute_all(price, line.order_id.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) - line.update({ - 'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])), - 'price_total': taxes['total_included'], - 'price_subtotal': taxes['total_excluded'], - }) - if self.env.context.get('import_file', False) and not self.env.user.user_has_groups('account.group_account_manager'): - line.tax_id.invalidate_cache(['invoice_repartition_line_ids'], [line.tax_id.id]) - - @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered') - def _compute_product_updatable(self): - for line in self: - if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)): - line.product_updatable = False - else: - line.product_updatable = True - - # no trigger product_id.invoice_policy to avoid retroactively changing SO - @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state') - def _get_to_invoice_qty(self): - """ - Compute the quantity to invoice. If the invoice policy is order, the quantity to invoice is - calculated from the ordered quantity. Otherwise, the quantity delivered is used. - """ - for line in self: - if line.order_id.state in ['sale', 'done']: - if line.product_id.invoice_policy == 'order': - line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced - else: - line.qty_to_invoice = line.qty_delivered - line.qty_invoiced - else: - line.qty_to_invoice = 0 - - @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'untaxed_amount_to_invoice') - def _get_invoice_qty(self): - """ - Compute the quantity invoiced. If case of a refund, the quantity invoiced is decreased. Note - that this is the case only if the refund is generated from the SO and that is intentional: if - a refund made would automatically decrease the invoiced quantity, then there is a risk of reinvoicing - it automatically, which may not be wanted at all. That's why the refund has to be created from the SO - """ - for line in self: - qty_invoiced = 0.0 - for invoice_line in line.invoice_lines: - if invoice_line.move_id.state != 'cancel': - if invoice_line.move_id.move_type == 'out_invoice': - qty_invoiced += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) - elif invoice_line.move_id.move_type == 'out_refund': - if not line.is_downpayment or line.untaxed_amount_to_invoice == 0 : - qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) - line.qty_invoiced = qty_invoiced - - @api.depends('price_unit', 'discount') - def _get_price_reduce(self): - for line in self: - line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0) - - @api.depends('price_total', 'product_uom_qty') - def _get_price_reduce_tax(self): - for line in self: - line.price_reduce_taxinc = line.price_total / line.product_uom_qty if line.product_uom_qty else 0.0 - - @api.depends('price_subtotal', 'product_uom_qty') - def _get_price_reduce_notax(self): - for line in self: - line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0 - - def _compute_tax_id(self): - for line in self: - line = line.with_company(line.company_id) - fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id.get_fiscal_position(line.order_partner_id.id) - # If company_id is set, always filter taxes by the company - taxes = line.product_id.taxes_id.filtered(lambda t: t.company_id == line.env.company) - line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id) - - @api.model - def _prepare_add_missing_fields(self, values): - """ Deduce missing required fields from the onchange """ - res = {} - onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_id'] - if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields): - line = self.new(values) - line.product_id_change() - for field in onchange_fields: - if field not in values: - res[field] = line._fields[field].convert_to_write(line[field], line) - return res - - @api.model_create_multi - def create(self, vals_list): - for values in vals_list: - if values.get('display_type', self.default_get(['display_type'])['display_type']): - values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) - - values.update(self._prepare_add_missing_fields(values)) - - lines = super().create(vals_list) - for line in lines: - if line.product_id and line.order_id.state == 'sale': - msg = _("Extra line with %s ") % (line.product_id.display_name,) - line.order_id.message_post(body=msg) - # create an analytic account if at least an expense product - if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id: - line.order_id._create_analytic_account() - return lines - - _sql_constraints = [ - ('accountable_required_fields', - "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL))", - "Missing required fields on accountable sale order line."), - ('non_accountable_null_fields', - "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND customer_lead = 0))", - "Forbidden values on non-accountable sale order line"), - ] - - def _update_line_quantity(self, values): - orders = self.mapped('order_id') - for order in orders: - order_lines = self.filtered(lambda x: x.order_id == order) - msg = "" + _("The ordered quantity has been updated.") + "" - order.message_post(body=msg) - - def write(self, values): - if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): - raise UserError(_("You cannot change the type of a sale order line. Instead you should delete the current line and create a new line of the proper type.")) - - if 'product_uom_qty' in values: - precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') - self.filtered( - lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0)._update_line_quantity(values) - - # Prevent writing on a locked SO. - protected_fields = self._get_protected_fields() - if 'done' in self.mapped('order_id.state') and any(f in values.keys() for f in protected_fields): - protected_fields_modified = list(set(protected_fields) & set(values.keys())) - fields = self.env['ir.model.fields'].search([ - ('name', 'in', protected_fields_modified), ('model', '=', self._name) - ]) - raise UserError( - _('It is forbidden to modify the following fields in a locked order:\n%s') - % '\n'.join(fields.mapped('field_description')) - ) - - result = super(SaleOrderLine, self).write(values) - return result - - order_id = fields.Many2one('sale.order', string='Order Reference', required=True, ondelete='cascade', index=True, copy=False) - name = fields.Text(string='Description', required=True) - sequence = fields.Integer(string='Sequence', default=10) - - invoice_lines = fields.Many2many('account.move.line', 'sale_order_line_invoice_rel', 'order_line_id', 'invoice_line_id', string='Invoice Lines', copy=False) - invoice_status = fields.Selection([ - ('upselling', 'Upselling Opportunity'), - ('invoiced', 'Fully Invoiced'), - ('to invoice', 'To Invoice'), - ('no', 'Nothing to Invoice') - ], string='Invoice Status', compute='_compute_invoice_status', store=True, readonly=True, default='no') - price_unit = fields.Float('Unit Price', required=True, digits='Product Price', default=0.0) - - price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', readonly=True, store=True) - price_tax = fields.Float(compute='_compute_amount', string='Total Tax', readonly=True, store=True) - price_total = fields.Monetary(compute='_compute_amount', string='Total', readonly=True, store=True) - - price_reduce = fields.Float(compute='_get_price_reduce', string='Price Reduce', digits='Product Price', readonly=True, store=True) - tax_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) - price_reduce_taxinc = fields.Monetary(compute='_get_price_reduce_tax', string='Price Reduce Tax inc', readonly=True, store=True) - price_reduce_taxexcl = fields.Monetary(compute='_get_price_reduce_notax', string='Price Reduce Tax excl', readonly=True, store=True) - - discount = fields.Float(string='Discount (%)', digits='Discount', default=0.0) - - product_id = fields.Many2one( - 'product.product', string='Product', domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", - change_default=True, ondelete='restrict', check_company=True) # Unrequired company - product_template_id = fields.Many2one( - 'product.template', string='Product Template', - related="product_id.product_tmpl_id", domain=[('sale_ok', '=', True)]) - product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True) - product_uom_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, default=1.0) - product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]", ondelete="restrict") - product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) - product_uom_readonly = fields.Boolean(compute='_compute_product_uom_readonly') - product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string="Custom Values", copy=True) - - # M2M holding the values of product.attribute with create_variant field set to 'no_variant' - # It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description - product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string="Extra Values", ondelete='restrict') - - qty_delivered_method = fields.Selection([ - ('manual', 'Manual'), - ('analytic', 'Analytic From Expenses') - ], string="Method to update delivered qty", compute='_compute_qty_delivered_method', compute_sudo=True, store=True, readonly=True, - help="According to product configuration, the delivered quantity can be automatically computed by mechanism :\n" - " - Manual: the quantity is set manually on the line\n" - " - Analytic From expenses: the quantity is the quantity sum from posted expenses\n" - " - Timesheet: the quantity is the sum of hours recorded on tasks linked to this sale line\n" - " - Stock Moves: the quantity comes from confirmed pickings\n") - qty_delivered = fields.Float('Delivered Quantity', copy=False, compute='_compute_qty_delivered', inverse='_inverse_qty_delivered', compute_sudo=True, store=True, digits='Product Unit of Measure', default=0.0) - qty_delivered_manual = fields.Float('Delivered Manually', copy=False, digits='Product Unit of Measure', default=0.0) - qty_to_invoice = fields.Float( - compute='_get_to_invoice_qty', string='To Invoice Quantity', store=True, readonly=True, - digits='Product Unit of Measure') - qty_invoiced = fields.Float( - compute='_get_invoice_qty', string='Invoiced Quantity', store=True, readonly=True, - compute_sudo=True, - digits='Product Unit of Measure') - - untaxed_amount_invoiced = fields.Monetary("Untaxed Invoiced Amount", compute='_compute_untaxed_amount_invoiced', compute_sudo=True, store=True) - untaxed_amount_to_invoice = fields.Monetary("Untaxed Amount To Invoice", compute='_compute_untaxed_amount_to_invoice', compute_sudo=True, store=True) - - salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', readonly=True) - currency_id = fields.Many2one(related='order_id.currency_id', depends=['order_id.currency_id'], store=True, string='Currency', readonly=True) - company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True, index=True) - order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer', readonly=False) - analytic_tag_ids = fields.Many2many( - 'account.analytic.tag', string='Analytic Tags', - domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") - analytic_line_ids = fields.One2many('account.analytic.line', 'so_line', string="Analytic lines") - is_expense = fields.Boolean('Is expense', help="Is true if the sales order line comes from an expense or a vendor bills") - is_downpayment = fields.Boolean( - string="Is a down payment", help="Down payments are made when creating invoices from a sales order." - " They are not copied when duplicating a sales order.") - - state = fields.Selection( - related='order_id.state', string='Order Status', readonly=True, copy=False, store=True, default='draft') - - customer_lead = fields.Float( - 'Lead Time', required=True, default=0.0, - help="Number of days between the order confirmation and the shipping of the products to the customer") - - display_type = fields.Selection([ - ('line_section', "Section"), - ('line_note', "Note")], default=False, help="Technical field for UX purpose.") - - product_packaging_id = fields.Many2one('product.packaging', string='Packaging', default=False, domain="[('sales', '=', True), ('product_id','=',product_id)]", check_company=True) - product_packaging_qty = fields.Float('Packaging Quantity') - - @api.depends('state') - def _compute_product_uom_readonly(self): - for line in self: - line.product_uom_readonly = line.state in ['sale', 'done', 'cancel'] - - @api.depends('state', 'is_expense') - def _compute_qty_delivered_method(self): - """ Sale module compute delivered qty for product [('type', 'in', ['consu']), ('service_type', '=', 'manual')] - - consu + expense_policy : analytic (sum of analytic unit_amount) - - consu + no expense_policy : manual (set manually on SOL) - - service (+ service_type='manual', the only available option) : manual - - This is true when only sale is installed: sale_stock redifine the behavior for 'consu' type, - and sale_timesheet implements the behavior of 'service' + service_type=timesheet. - """ - for line in self: - if line.is_expense: - line.qty_delivered_method = 'analytic' - else: # service and consu - line.qty_delivered_method = 'manual' - - @api.depends('qty_delivered_method', 'qty_delivered_manual', 'analytic_line_ids.so_line', 'analytic_line_ids.unit_amount', 'analytic_line_ids.product_uom_id') - def _compute_qty_delivered(self): - """ This method compute the delivered quantity of the SO lines: it covers the case provide by sale module, aka - expense/vendor bills (sum of unit_amount of AAL), and manual case. - This method should be overridden to provide other way to automatically compute delivered qty. Overrides should - take their concerned so lines, compute and set the `qty_delivered` field, and call super with the remaining - records. - """ - # compute for analytic lines - lines_by_analytic = self.filtered(lambda sol: sol.qty_delivered_method == 'analytic') - mapping = lines_by_analytic._get_delivered_quantity_by_analytic([('amount', '<=', 0.0)]) - for so_line in lines_by_analytic: - so_line.qty_delivered = mapping.get(so_line.id or so_line._origin.id, 0.0) - # compute for manual lines - for line in self: - if line.qty_delivered_method == 'manual': - line.qty_delivered = line.qty_delivered_manual or 0.0 - - def _get_delivered_quantity_by_analytic(self, additional_domain): - """ Compute and write the delivered quantity of current SO lines, based on their related - analytic lines. - :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...) - """ - result = {} - - # avoid recomputation if no SO lines concerned - if not self: - return result - - # group analytic lines by product uom and so line - domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain]) - data = self.env['account.analytic.line'].read_group( - domain, - ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False - ) - - # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines - # browse so lines and product uoms here to make them share the same prefetch - lines = self.browse([item['so_line'][0] for item in data]) - lines_map = {line.id: line for line in lines} - product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']] - product_uom_map = {uom.id: uom for uom in self.env['uom.uom'].browse(product_uom_ids)} - for item in data: - if not item['product_uom_id']: - continue - so_line_id = item['so_line'][0] - so_line = lines_map[so_line_id] - result.setdefault(so_line_id, 0.0) - uom = product_uom_map.get(item['product_uom_id'][0]) - if so_line.product_uom.category_id == uom.category_id: - qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom, rounding_method='HALF-UP') - else: - qty = item['unit_amount'] - result[so_line_id] += qty - - return result - - @api.onchange('qty_delivered') - def _inverse_qty_delivered(self): - """ When writing on qty_delivered, if the value should be modify manually (`qty_delivered_method` = 'manual' only), - then we put the value in `qty_delivered_manual`. Otherwise, `qty_delivered_manual` should be False since the - delivered qty is automatically compute by other mecanisms. - """ - for line in self: - if line.qty_delivered_method == 'manual': - line.qty_delivered_manual = line.qty_delivered - else: - line.qty_delivered_manual = 0.0 - - @api.onchange('product_id', 'product_uom_qty', 'product_uom') - def _onchange_suggest_packaging(self): - # remove packaging if not match the product - if self.product_packaging_id.product_id != self.product_id: - self.product_packaging_id = False - # suggest biggest suitable packaging - if self.product_id and self.product_uom_qty and self.product_uom: - self.product_packaging_id = self.product_id.packaging_ids.filtered('sales')._find_suitable_product_packaging(self.product_uom_qty, self.product_uom) - - @api.onchange('product_packaging_id') - def _onchange_product_packaging_id(self): - if self.product_packaging_id and self.product_uom_qty: - newqty = self.product_packaging_id._check_qty(self.product_uom_qty, self.product_uom, "UP") - if float_compare(newqty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: - return { - 'warning': { - 'title': _('Warning'), - 'message': _( - "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.", - pack_size=self.product_packaging_id.qty, - pack_name=self.product_id.uom_id.name, - quantity=newqty, - unit=self.product_uom.name - ), - }, - } - - @api.onchange('product_packaging_id', 'product_uom', 'product_uom_qty') - def _onchange_update_product_packaging_qty(self): - if not self.product_packaging_id: - self.product_packaging_qty = False - else: - packaging_uom = self.product_packaging_id.product_uom_id - packaging_uom_qty = self.product_uom._compute_quantity(self.product_uom_qty, packaging_uom) - self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding) - - @api.onchange('product_packaging_qty') - def _onchange_product_packaging_qty(self): - if self.product_packaging_id: - packaging_uom = self.product_packaging_id.product_uom_id - qty_per_packaging = self.product_packaging_id.qty - product_uom_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom) - if float_compare(product_uom_qty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: - self.product_uom_qty = product_uom_qty - - @api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state', 'invoice_lines.move_id.move_type') - def _compute_untaxed_amount_invoiced(self): - """ Compute the untaxed amount already invoiced from the sale order line, taking the refund attached - the so line into account. This amount is computed as - SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) - where - `inv_line` is a customer invoice line linked to the SO line - `ref_line` is a customer credit note (refund) line linked to the SO line - """ - for line in self: - amount_invoiced = 0.0 - for invoice_line in line.invoice_lines: - if invoice_line.move_id.state == 'posted': - invoice_date = invoice_line.move_id.invoice_date or fields.Date.today() - if invoice_line.move_id.move_type == 'out_invoice': - amount_invoiced += invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) - elif invoice_line.move_id.move_type == 'out_refund': - amount_invoiced -= invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) - line.untaxed_amount_invoiced = amount_invoiced - - @api.depends('state', 'price_reduce', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty') - def _compute_untaxed_amount_to_invoice(self): - """ Total of remaining amount to invoice on the sale order line (taxes excl.) as - total_sol - amount already invoiced - where Total_sol depends on the invoice policy of the product. - - Note: Draft invoice are ignored on purpose, the 'to invoice' amount should - come only from the SO lines. - """ - for line in self: - amount_to_invoice = 0.0 - if line.state in ['sale', 'done']: - # Note: do not use price_subtotal field as it returns zero when the ordered quantity is - # zero. It causes problem for expense line (e.i.: ordered qty = 0, deli qty = 4, - # price_unit = 20 ; subtotal is zero), but when you can invoice the line, you see an - # amount and not zero. Since we compute untaxed amount, we can use directly the price - # reduce (to include discount) without using `compute_all()` method on taxes. - price_subtotal = 0.0 - umo_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty - price_subtotal = line.price_reduce * umo_qty_to_consider - if len(line.tax_id.filtered(lambda tax: tax.price_include)) > 0: - # As included taxes are not excluded from the computed subtotal, `compute_all()` method - # has to be called to retrieve the subtotal without them. - # `price_reduce_taxexcl` cannot be used as it is computed from `price_subtotal` field. (see upper Note) - price_subtotal = line.tax_id.compute_all( - line.price_reduce, - currency=line.order_id.currency_id, - quantity=umo_qty_to_consider, - product=line.product_id, - partner=line.order_id.partner_shipping_id)['total_excluded'] - - if any(line.invoice_lines.mapped(lambda l: l.discount != line.discount)): - # In case of re-invoicing with different discount we try to calculate manually the - # remaining amount to invoice - amount = 0 - for l in line.invoice_lines: - if len(l.tax_ids.filtered(lambda tax: tax.price_include)) > 0: - amount += l.tax_ids.compute_all(l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity)['total_excluded'] - else: - amount += l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity - - amount_to_invoice = max(price_subtotal - amount, 0) - else: - amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced - - line.untaxed_amount_to_invoice = amount_to_invoice - - def _get_invoice_line_sequence(self, new=0, old=0): - """ - Method intended to be overridden in third-party module if we want to prevent the resequencing - of invoice lines. - - :param int new: the new line sequence - :param int old: the old line sequence - - :return: the sequence of the SO line, by default the new one. - """ - return new or old - - def _prepare_invoice_line(self, **optional_values): - """ - Prepare the dict of values to create the new invoice line for a sales order line. - - :param qty: float quantity to invoice - :param optional_values: any parameter that should be added to the returned invoice line - """ - self.ensure_one() - res = { - 'display_type': self.display_type, - 'sequence': self.sequence, - 'name': self.name, - 'product_id': self.product_id.id, - 'product_uom_id': self.product_uom.id, - 'quantity': self.qty_to_invoice, - 'discount': self.discount, - 'price_unit': self.price_unit, - 'tax_ids': [(6, 0, self.tax_id.ids)], - 'analytic_account_id': self.order_id.analytic_account_id.id, - 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], - 'sale_line_ids': [(4, self.id)], - } - if optional_values: - res.update(optional_values) - if self.display_type: - res['account_id'] = False - return res - - def _prepare_procurement_values(self, group_id=False): - """ Prepare specific key for moves or other components that will be created from a stock rule - comming from a sale order line. This method could be override in order to add other custom key that could - be used in move/po creation. - """ - return {} - - def _get_display_price(self, product): - # TO DO: move me in master/saas-16 on sale.order - # awa: don't know if it's still the case since we need the "product_no_variant_attribute_value_ids" field now - # to be able to compute the full price - - # it is possible that a no_variant attribute is still in a variant if - # the type of the attribute has been changed after creation. - no_variant_attributes_price_extra = [ - ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered( - lambda ptav: - ptav.price_extra and - ptav not in product.product_template_attribute_value_ids - ) - ] - if no_variant_attributes_price_extra: - product = product.with_context( - no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra) - ) - - if self.order_id.pricelist_id.discount_policy == 'with_discount': - return product.with_context(pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id).price - product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) - - final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) - base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) - if currency != self.order_id.pricelist_id.currency_id: - base_price = currency._convert( - base_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) - # negative discounts (= surcharge) are included in the display price - return max(base_price, final_price) - - @api.onchange('product_id') - def product_id_change(self): - if not self.product_id: - return - valid_values = self.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids - # remove the is_custom values that don't belong to this template - for pacv in self.product_custom_attribute_value_ids: - if pacv.custom_product_template_attribute_value_id not in valid_values: - self.product_custom_attribute_value_ids -= pacv - - # remove the no_variant attributes that don't belong to this template - for ptav in self.product_no_variant_attribute_value_ids: - if ptav._origin not in valid_values: - self.product_no_variant_attribute_value_ids -= ptav - - vals = {} - if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id): - vals['product_uom'] = self.product_id.uom_id - vals['product_uom_qty'] = self.product_uom_qty or 1.0 - - product = self.product_id.with_context( - lang=get_lang(self.env, self.order_id.partner_id.lang).code, - partner=self.order_id.partner_id, - quantity=vals.get('product_uom_qty') or self.product_uom_qty, - date=self.order_id.date_order, - pricelist=self.order_id.pricelist_id.id, - uom=self.product_uom.id - ) - - vals.update(name=self.get_sale_order_line_multiline_description_sale(product)) - - self._compute_tax_id() - - if self.order_id.pricelist_id and self.order_id.partner_id: - vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) - self.update(vals) - - title = False - message = False - result = {} - warning = {} - if product.sale_line_warn != 'no-message': - title = _("Warning for %s", product.name) - message = product.sale_line_warn_msg - warning['title'] = title - warning['message'] = message - result = {'warning': warning} - if product.sale_line_warn == 'block': - self.product_id = False - - return result - - @api.onchange('product_uom', 'product_uom_qty') - def product_uom_change(self): - if not self.product_uom or not self.product_id: - self.price_unit = 0.0 - return - if self.order_id.pricelist_id and self.order_id.partner_id: - product = self.product_id.with_context( - lang=self.order_id.partner_id.lang, - partner=self.order_id.partner_id, - quantity=self.product_uom_qty, - date=self.order_id.date_order, - pricelist=self.order_id.pricelist_id.id, - uom=self.product_uom.id, - fiscal_position=self.env.context.get('fiscal_position') - ) - self.price_unit = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) - - def name_get(self): - result = [] - for so_line in self.sudo(): - name = '%s - %s' % (so_line.order_id.name, so_line.name and so_line.name.split('\n')[0] or so_line.product_id.name) - if so_line.order_partner_id.ref: - name = '%s (%s)' % (name, so_line.order_partner_id.ref) - result.append((so_line.id, name)) - return result - - @api.model - def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): - if operator in ('ilike', 'like', '=', '=like', '=ilike'): - args = expression.AND([ - args or [], - ['|', ('order_id.name', operator, name), ('name', operator, name)] - ]) - return super(SaleOrderLine, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) - - def _check_line_unlink(self): - """ - Check wether a line can be deleted or not. - - Lines cannot be deleted if the order is confirmed; downpayment - lines who have not yet been invoiced bypass that exception. - :rtype: recordset sale.order.line - :returns: set of lines that cannot be deleted - """ - return self.filtered(lambda line: line.state in ('sale', 'done') and (line.invoice_lines or not line.is_downpayment)) - - @api.ondelete(at_uninstall=False) - def _unlink_except_confirmed(self): - if self._check_line_unlink(): - raise UserError(_('You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.')) - - def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id): - """Retrieve the price before applying the pricelist - :param obj product: object of current product record - :parem float qty: total quentity of product - :param tuple price_and_rule: tuple(price, suitable_rule) coming from pricelist computation - :param obj uom: unit of measure of current order line - :param integer pricelist_id: pricelist id of sales order""" - PricelistItem = self.env['product.pricelist.item'] - field_name = 'lst_price' - currency_id = None - product_currency = product.currency_id - if rule_id: - pricelist_item = PricelistItem.browse(rule_id) - if pricelist_item.pricelist_id.discount_policy == 'without_discount': - while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id and pricelist_item.base_pricelist_id.discount_policy == 'without_discount': - price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id) - pricelist_item = PricelistItem.browse(rule_id) - - if pricelist_item.base == 'standard_price': - field_name = 'standard_price' - product_currency = product.cost_currency_id - elif pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id: - field_name = 'price' - product = product.with_context(pricelist=pricelist_item.base_pricelist_id.id) - product_currency = pricelist_item.base_pricelist_id.currency_id - currency_id = pricelist_item.pricelist_id.currency_id - - if not currency_id: - currency_id = product_currency - cur_factor = 1.0 - else: - if currency_id.id == product_currency.id: - cur_factor = 1.0 - else: - cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) - - product_uom = self.env.context.get('uom') or product.uom_id.id - if uom and uom.id != product_uom: - # the unit price is in a different uom - uom_factor = uom._compute_price(1.0, product.uom_id) - else: - uom_factor = 1.0 - - return product[field_name] * uom_factor * cur_factor, currency_id - - def _get_protected_fields(self): - return [ - 'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty', - 'tax_id', 'analytic_tag_ids' - ] - - def _onchange_product_id_set_customer_lead(self): - pass - - @api.onchange('product_id', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id') - def _onchange_discount(self): - if not (self.product_id and self.product_uom and - self.order_id.partner_id and self.order_id.pricelist_id and - self.order_id.pricelist_id.discount_policy == 'without_discount' and - self.env.user.has_group('product.group_discount_per_so_line')): - return - - self.discount = 0.0 - product = self.product_id.with_context( - lang=self.order_id.partner_id.lang, - partner=self.order_id.partner_id, - quantity=self.product_uom_qty, - date=self.order_id.date_order, - pricelist=self.order_id.pricelist_id.id, - uom=self.product_uom.id, - fiscal_position=self.env.context.get('fiscal_position') - ) - - product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) - - price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) - new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) - - if new_list_price != 0: - if self.order_id.pricelist_id.currency_id != currency: - # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency - new_list_price = currency._convert( - new_list_price, self.order_id.pricelist_id.currency_id, - self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) - discount = (new_list_price - price) / new_list_price * 100 - if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0): - self.discount = discount - - def _is_delivery(self): - self.ensure_one() - return False - - def get_sale_order_line_multiline_description_sale(self, product): - """ Compute a default multiline description for this sales order line. - - In most cases the product description is enough but sometimes we need to append information that only - exists on the sale order line itself. - e.g: - - custom attributes and attributes that don't create variants, both introduced by the "product configurator" - - in event_sale we need to know specifically the sales order line as well as the product to generate the name: - the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line). - """ - return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants() - - def _get_sale_order_line_multiline_description_variants(self): - """When using no_variant attributes or is_custom values, the product - itself is not sufficient to create the description: we need to add - information about those special attributes and values. - - :return: the description related to special variant attributes/values - :rtype: string - """ - if not self.product_custom_attribute_value_ids and not self.product_no_variant_attribute_value_ids: - return "" - - name = "\n" - - custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id - no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin - - # display the no_variant attributes, except those that are also - # displayed by a custom (avoid duplicate description) - for ptav in (no_variant_ptavs - custom_ptavs): - name += "\n" + ptav.with_context(lang=self.order_id.partner_id.lang).display_name - - # Sort the values according to _order settings, because it doesn't work for virtual records in onchange - custom_values = sorted(self.product_custom_attribute_value_ids, key=lambda r: (r.custom_product_template_attribute_value_id.id, r.id)) - # display the is_custom values - for pacv in custom_values: - name += "\n" + pacv.with_context(lang=self.order_id.partner_id.lang).display_name - - return name - - def _is_not_sellable_line(self): - # True if the line is a computed line (reward, delivery, ...) that user cannot add manually - return False diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py new file mode 100644 index 00000000000..41d68abf60c --- /dev/null +++ b/addons/sale/models/sale_order_line.py @@ -0,0 +1,828 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.tools.misc import get_lang +from odoo.osv import expression +from odoo.tools import float_is_zero, float_compare, float_round + + +class SaleOrderLine(models.Model): + _name = 'sale.order.line' + _description = 'Sales Order Line' + _order = 'order_id, sequence, id' + _check_company_auto = True + + @api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced') + def _compute_invoice_status(self): + """ + Compute the invoice status of a SO line. Possible statuses: + - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to + invoice. This is also hte default value if the conditions of no other status is met. + - to invoice: we refer to the quantity to invoice of the line. Refer to method + `_get_to_invoice_qty()` for more information on how this quantity is calculated. + - upselling: this is possible only for a product invoiced on ordered quantities for which + we delivered more than expected. The could arise if, for example, a project took more + time than expected but we decided not to invoice the extra cost to the client. This + occurs onyl in state 'sale', so that when a SO is set to done, the upselling opportunity + is removed from the list. + - invoiced: the quantity invoiced is larger or equal to the quantity ordered. + """ + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + for line in self: + if line.state not in ('sale', 'done'): + line.invoice_status = 'no' + elif line.is_downpayment and line.untaxed_amount_to_invoice == 0: + line.invoice_status = 'invoiced' + elif not float_is_zero(line.qty_to_invoice, precision_digits=precision): + line.invoice_status = 'to invoice' + elif line.state == 'sale' and line.product_id.invoice_policy == 'order' and\ + float_compare(line.qty_delivered, line.product_uom_qty, precision_digits=precision) == 1: + line.invoice_status = 'upselling' + elif float_compare(line.qty_invoiced, line.product_uom_qty, precision_digits=precision) >= 0: + line.invoice_status = 'invoiced' + else: + line.invoice_status = 'no' + + def _expected_date(self): + self.ensure_one() + order_date = fields.Datetime.from_string(self.order_id.date_order if self.order_id.date_order and self.order_id.state in ['sale', 'done'] else fields.Datetime.now()) + return order_date + timedelta(days=self.customer_lead or 0.0) + + @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') + def _compute_amount(self): + """ + Compute the amounts of the SO line. + """ + for line in self: + price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) + taxes = line.tax_id.compute_all(price, line.order_id.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) + line.update({ + 'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])), + 'price_total': taxes['total_included'], + 'price_subtotal': taxes['total_excluded'], + }) + if self.env.context.get('import_file', False) and not self.env.user.user_has_groups('account.group_account_manager'): + line.tax_id.invalidate_cache(['invoice_repartition_line_ids'], [line.tax_id.id]) + + @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered') + def _compute_product_updatable(self): + for line in self: + if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)): + line.product_updatable = False + else: + line.product_updatable = True + + # no trigger product_id.invoice_policy to avoid retroactively changing SO + @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state') + def _get_to_invoice_qty(self): + """ + Compute the quantity to invoice. If the invoice policy is order, the quantity to invoice is + calculated from the ordered quantity. Otherwise, the quantity delivered is used. + """ + for line in self: + if line.order_id.state in ['sale', 'done']: + if line.product_id.invoice_policy == 'order': + line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced + else: + line.qty_to_invoice = line.qty_delivered - line.qty_invoiced + else: + line.qty_to_invoice = 0 + + @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'untaxed_amount_to_invoice') + def _get_invoice_qty(self): + """ + Compute the quantity invoiced. If case of a refund, the quantity invoiced is decreased. Note + that this is the case only if the refund is generated from the SO and that is intentional: if + a refund made would automatically decrease the invoiced quantity, then there is a risk of reinvoicing + it automatically, which may not be wanted at all. That's why the refund has to be created from the SO + """ + for line in self: + qty_invoiced = 0.0 + for invoice_line in line.invoice_lines: + if invoice_line.move_id.state != 'cancel': + if invoice_line.move_id.move_type == 'out_invoice': + qty_invoiced += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) + elif invoice_line.move_id.move_type == 'out_refund': + if not line.is_downpayment or line.untaxed_amount_to_invoice == 0: + qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom) + line.qty_invoiced = qty_invoiced + + @api.depends('price_unit', 'discount') + def _get_price_reduce(self): + for line in self: + line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0) + + @api.depends('price_total', 'product_uom_qty') + def _get_price_reduce_tax(self): + for line in self: + line.price_reduce_taxinc = line.price_total / line.product_uom_qty if line.product_uom_qty else 0.0 + + @api.depends('price_subtotal', 'product_uom_qty') + def _get_price_reduce_notax(self): + for line in self: + line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0 + + def _compute_tax_id(self): + for line in self: + line = line.with_company(line.company_id) + fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id.get_fiscal_position(line.order_partner_id.id) + # If company_id is set, always filter taxes by the company + taxes = line.product_id.taxes_id.filtered(lambda t: t.company_id == line.env.company) + line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id) + + @api.model + def _prepare_add_missing_fields(self, values): + """ Deduce missing required fields from the onchange """ + res = {} + onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_id'] + if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields): + line = self.new(values) + line.product_id_change() + for field in onchange_fields: + if field not in values: + res[field] = line._fields[field].convert_to_write(line[field], line) + return res + + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + if values.get('display_type', self.default_get(['display_type'])['display_type']): + values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) + + values.update(self._prepare_add_missing_fields(values)) + + lines = super().create(vals_list) + for line in lines: + if line.product_id and line.order_id.state == 'sale': + msg = _("Extra line with %s ") % (line.product_id.display_name,) + line.order_id.message_post(body=msg) + # create an analytic account if at least an expense product + if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id: + line.order_id._create_analytic_account() + return lines + + _sql_constraints = [ + ('accountable_required_fields', + "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL))", + "Missing required fields on accountable sale order line."), + ('non_accountable_null_fields', + "CHECK(display_type IS NULL OR (product_id IS NULL AND price_unit = 0 AND product_uom_qty = 0 AND product_uom IS NULL AND customer_lead = 0))", + "Forbidden values on non-accountable sale order line"), + ] + + def _update_line_quantity(self, values): + orders = self.mapped('order_id') + for order in orders: + order_lines = self.filtered(lambda x: x.order_id == order) + msg = "" + _("The ordered quantity has been updated.") + "" + order.message_post(body=msg) + + def write(self, values): + if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')): + raise UserError(_("You cannot change the type of a sale order line. Instead you should delete the current line and create a new line of the proper type.")) + + if 'product_uom_qty' in values: + precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') + self.filtered( + lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0)._update_line_quantity(values) + + # Prevent writing on a locked SO. + protected_fields = self._get_protected_fields() + if 'done' in self.mapped('order_id.state') and any(f in values.keys() for f in protected_fields): + protected_fields_modified = list(set(protected_fields) & set(values.keys())) + fields = self.env['ir.model.fields'].search([ + ('name', 'in', protected_fields_modified), ('model', '=', self._name) + ]) + raise UserError( + _('It is forbidden to modify the following fields in a locked order:\n%s') + % '\n'.join(fields.mapped('field_description')) + ) + + result = super(SaleOrderLine, self).write(values) + return result + + order_id = fields.Many2one('sale.order', string='Order Reference', required=True, ondelete='cascade', index=True, copy=False) + name = fields.Text(string='Description', required=True) + sequence = fields.Integer(string='Sequence', default=10) + + invoice_lines = fields.Many2many('account.move.line', 'sale_order_line_invoice_rel', 'order_line_id', 'invoice_line_id', string='Invoice Lines', copy=False) + invoice_status = fields.Selection([ + ('upselling', 'Upselling Opportunity'), + ('invoiced', 'Fully Invoiced'), + ('to invoice', 'To Invoice'), + ('no', 'Nothing to Invoice') + ], string='Invoice Status', compute='_compute_invoice_status', store=True, readonly=True, default='no') + price_unit = fields.Float('Unit Price', required=True, digits='Product Price', default=0.0) + + price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', readonly=True, store=True) + price_tax = fields.Float(compute='_compute_amount', string='Total Tax', readonly=True, store=True) + price_total = fields.Monetary(compute='_compute_amount', string='Total', readonly=True, store=True) + + price_reduce = fields.Float(compute='_get_price_reduce', string='Price Reduce', digits='Product Price', readonly=True, store=True) + tax_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) + price_reduce_taxinc = fields.Monetary(compute='_get_price_reduce_tax', string='Price Reduce Tax inc', readonly=True, store=True) + price_reduce_taxexcl = fields.Monetary(compute='_get_price_reduce_notax', string='Price Reduce Tax excl', readonly=True, store=True) + + discount = fields.Float(string='Discount (%)', digits='Discount', default=0.0) + + product_id = fields.Many2one( + 'product.product', string='Product', domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", + change_default=True, ondelete='restrict', check_company=True) # Unrequired company + product_template_id = fields.Many2one( + 'product.template', string='Product Template', + related="product_id.product_tmpl_id", domain=[('sale_ok', '=', True)]) + product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True) + product_uom_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, default=1.0) + product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]", ondelete="restrict") + product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True) + product_uom_readonly = fields.Boolean(compute='_compute_product_uom_readonly') + product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string="Custom Values", copy=True) + + # M2M holding the values of product.attribute with create_variant field set to 'no_variant' + # It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description + product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string="Extra Values", ondelete='restrict') + + qty_delivered_method = fields.Selection([ + ('manual', 'Manual'), + ('analytic', 'Analytic From Expenses') + ], string="Method to update delivered qty", compute='_compute_qty_delivered_method', compute_sudo=True, store=True, readonly=True, + help="According to product configuration, the delivered quantity can be automatically computed by mechanism :\n" + " - Manual: the quantity is set manually on the line\n" + " - Analytic From expenses: the quantity is the quantity sum from posted expenses\n" + " - Timesheet: the quantity is the sum of hours recorded on tasks linked to this sale line\n" + " - Stock Moves: the quantity comes from confirmed pickings\n") + qty_delivered = fields.Float('Delivered Quantity', copy=False, compute='_compute_qty_delivered', inverse='_inverse_qty_delivered', compute_sudo=True, store=True, digits='Product Unit of Measure', default=0.0) + qty_delivered_manual = fields.Float('Delivered Manually', copy=False, digits='Product Unit of Measure', default=0.0) + qty_to_invoice = fields.Float( + compute='_get_to_invoice_qty', string='To Invoice Quantity', store=True, readonly=True, + digits='Product Unit of Measure') + qty_invoiced = fields.Float( + compute='_get_invoice_qty', string='Invoiced Quantity', store=True, readonly=True, + compute_sudo=True, + digits='Product Unit of Measure') + + untaxed_amount_invoiced = fields.Monetary("Untaxed Invoiced Amount", compute='_compute_untaxed_amount_invoiced', compute_sudo=True, store=True) + untaxed_amount_to_invoice = fields.Monetary("Untaxed Amount To Invoice", compute='_compute_untaxed_amount_to_invoice', compute_sudo=True, store=True) + + salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', readonly=True) + currency_id = fields.Many2one(related='order_id.currency_id', depends=['order_id.currency_id'], store=True, string='Currency', readonly=True) + company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True, index=True) + order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer', readonly=False) + analytic_tag_ids = fields.Many2many( + 'account.analytic.tag', string='Analytic Tags', + domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") + analytic_line_ids = fields.One2many('account.analytic.line', 'so_line', string="Analytic lines") + is_expense = fields.Boolean('Is expense', help="Is true if the sales order line comes from an expense or a vendor bills") + is_downpayment = fields.Boolean( + string="Is a down payment", help="Down payments are made when creating invoices from a sales order." + " They are not copied when duplicating a sales order.") + + state = fields.Selection( + related='order_id.state', string='Order Status', readonly=True, copy=False, store=True, default='draft') + + customer_lead = fields.Float( + 'Lead Time', required=True, default=0.0, + help="Number of days between the order confirmation and the shipping of the products to the customer") + + display_type = fields.Selection([ + ('line_section', "Section"), + ('line_note', "Note")], default=False, help="Technical field for UX purpose.") + + product_packaging_id = fields.Many2one('product.packaging', string='Packaging', default=False, domain="[('sales', '=', True), ('product_id','=',product_id)]", check_company=True) + product_packaging_qty = fields.Float('Packaging Quantity') + + @api.depends('state') + def _compute_product_uom_readonly(self): + for line in self: + line.product_uom_readonly = line.state in ['sale', 'done', 'cancel'] + + @api.depends('state', 'is_expense') + def _compute_qty_delivered_method(self): + """ Sale module compute delivered qty for product [('type', 'in', ['consu']), ('service_type', '=', 'manual')] + - consu + expense_policy : analytic (sum of analytic unit_amount) + - consu + no expense_policy : manual (set manually on SOL) + - service (+ service_type='manual', the only available option) : manual + + This is true when only sale is installed: sale_stock redifine the behavior for 'consu' type, + and sale_timesheet implements the behavior of 'service' + service_type=timesheet. + """ + for line in self: + if line.is_expense: + line.qty_delivered_method = 'analytic' + else: # service and consu + line.qty_delivered_method = 'manual' + + @api.depends('qty_delivered_method', 'qty_delivered_manual', 'analytic_line_ids.so_line', 'analytic_line_ids.unit_amount', 'analytic_line_ids.product_uom_id') + def _compute_qty_delivered(self): + """ This method compute the delivered quantity of the SO lines: it covers the case provide by sale module, aka + expense/vendor bills (sum of unit_amount of AAL), and manual case. + This method should be overridden to provide other way to automatically compute delivered qty. Overrides should + take their concerned so lines, compute and set the `qty_delivered` field, and call super with the remaining + records. + """ + # compute for analytic lines + lines_by_analytic = self.filtered(lambda sol: sol.qty_delivered_method == 'analytic') + mapping = lines_by_analytic._get_delivered_quantity_by_analytic([('amount', '<=', 0.0)]) + for so_line in lines_by_analytic: + so_line.qty_delivered = mapping.get(so_line.id or so_line._origin.id, 0.0) + # compute for manual lines + for line in self: + if line.qty_delivered_method == 'manual': + line.qty_delivered = line.qty_delivered_manual or 0.0 + + def _get_delivered_quantity_by_analytic(self, additional_domain): + """ Compute and write the delivered quantity of current SO lines, based on their related + analytic lines. + :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...) + """ + result = {} + + # avoid recomputation if no SO lines concerned + if not self: + return result + + # group analytic lines by product uom and so line + domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain]) + data = self.env['account.analytic.line'].read_group( + domain, + ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False + ) + + # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines + # browse so lines and product uoms here to make them share the same prefetch + lines = self.browse([item['so_line'][0] for item in data]) + lines_map = {line.id: line for line in lines} + product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']] + product_uom_map = {uom.id: uom for uom in self.env['uom.uom'].browse(product_uom_ids)} + for item in data: + if not item['product_uom_id']: + continue + so_line_id = item['so_line'][0] + so_line = lines_map[so_line_id] + result.setdefault(so_line_id, 0.0) + uom = product_uom_map.get(item['product_uom_id'][0]) + if so_line.product_uom.category_id == uom.category_id: + qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom, rounding_method='HALF-UP') + else: + qty = item['unit_amount'] + result[so_line_id] += qty + + return result + + @api.onchange('qty_delivered') + def _inverse_qty_delivered(self): + """ When writing on qty_delivered, if the value should be modify manually (`qty_delivered_method` = 'manual' only), + then we put the value in `qty_delivered_manual`. Otherwise, `qty_delivered_manual` should be False since the + delivered qty is automatically compute by other mecanisms. + """ + for line in self: + if line.qty_delivered_method == 'manual': + line.qty_delivered_manual = line.qty_delivered + else: + line.qty_delivered_manual = 0.0 + + @api.onchange('product_id', 'product_uom_qty', 'product_uom') + def _onchange_suggest_packaging(self): + # remove packaging if not match the product + if self.product_packaging_id.product_id != self.product_id: + self.product_packaging_id = False + # suggest biggest suitable packaging + if self.product_id and self.product_uom_qty and self.product_uom: + self.product_packaging_id = self.product_id.packaging_ids.filtered('sales')._find_suitable_product_packaging(self.product_uom_qty, self.product_uom) + + @api.onchange('product_packaging_id') + def _onchange_product_packaging_id(self): + if self.product_packaging_id and self.product_uom_qty: + newqty = self.product_packaging_id._check_qty(self.product_uom_qty, self.product_uom, "UP") + if float_compare(newqty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: + return { + 'warning': { + 'title': _('Warning'), + 'message': _( + "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.", + pack_size=self.product_packaging_id.qty, + pack_name=self.product_id.uom_id.name, + quantity=newqty, + unit=self.product_uom.name + ), + }, + } + + @api.onchange('product_packaging_id', 'product_uom', 'product_uom_qty') + def _onchange_update_product_packaging_qty(self): + if not self.product_packaging_id: + self.product_packaging_qty = False + else: + packaging_uom = self.product_packaging_id.product_uom_id + packaging_uom_qty = self.product_uom._compute_quantity(self.product_uom_qty, packaging_uom) + self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding) + + @api.onchange('product_packaging_qty') + def _onchange_product_packaging_qty(self): + if self.product_packaging_id: + packaging_uom = self.product_packaging_id.product_uom_id + qty_per_packaging = self.product_packaging_id.qty + product_uom_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom) + if float_compare(product_uom_qty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: + self.product_uom_qty = product_uom_qty + + @api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state', 'invoice_lines.move_id.move_type') + def _compute_untaxed_amount_invoiced(self): + """ Compute the untaxed amount already invoiced from the sale order line, taking the refund attached + the so line into account. This amount is computed as + SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal) + where + `inv_line` is a customer invoice line linked to the SO line + `ref_line` is a customer credit note (refund) line linked to the SO line + """ + for line in self: + amount_invoiced = 0.0 + for invoice_line in line.invoice_lines: + if invoice_line.move_id.state == 'posted': + invoice_date = invoice_line.move_id.invoice_date or fields.Date.today() + if invoice_line.move_id.move_type == 'out_invoice': + amount_invoiced += invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) + elif invoice_line.move_id.move_type == 'out_refund': + amount_invoiced -= invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date) + line.untaxed_amount_invoiced = amount_invoiced + + @api.depends('state', 'price_reduce', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty') + def _compute_untaxed_amount_to_invoice(self): + """ Total of remaining amount to invoice on the sale order line (taxes excl.) as + total_sol - amount already invoiced + where Total_sol depends on the invoice policy of the product. + + Note: Draft invoice are ignored on purpose, the 'to invoice' amount should + come only from the SO lines. + """ + for line in self: + amount_to_invoice = 0.0 + if line.state in ['sale', 'done']: + # Note: do not use price_subtotal field as it returns zero when the ordered quantity is + # zero. It causes problem for expense line (e.i.: ordered qty = 0, deli qty = 4, + # price_unit = 20 ; subtotal is zero), but when you can invoice the line, you see an + # amount and not zero. Since we compute untaxed amount, we can use directly the price + # reduce (to include discount) without using `compute_all()` method on taxes. + price_subtotal = 0.0 + umo_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty + price_subtotal = line.price_reduce * umo_qty_to_consider + if len(line.tax_id.filtered(lambda tax: tax.price_include)) > 0: + # As included taxes are not excluded from the computed subtotal, `compute_all()` method + # has to be called to retrieve the subtotal without them. + # `price_reduce_taxexcl` cannot be used as it is computed from `price_subtotal` field. (see upper Note) + price_subtotal = line.tax_id.compute_all( + line.price_reduce, + currency=line.order_id.currency_id, + quantity=umo_qty_to_consider, + product=line.product_id, + partner=line.order_id.partner_shipping_id)['total_excluded'] + + if any(line.invoice_lines.mapped(lambda l: l.discount != line.discount)): + # In case of re-invoicing with different discount we try to calculate manually the + # remaining amount to invoice + amount = 0 + for l in line.invoice_lines: + if len(l.tax_ids.filtered(lambda tax: tax.price_include)) > 0: + amount += l.tax_ids.compute_all(l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity)['total_excluded'] + else: + amount += l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity + + amount_to_invoice = max(price_subtotal - amount, 0) + else: + amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced + + line.untaxed_amount_to_invoice = amount_to_invoice + + def _get_invoice_line_sequence(self, new=0, old=0): + """ + Method intended to be overridden in third-party module if we want to prevent the resequencing + of invoice lines. + + :param int new: the new line sequence + :param int old: the old line sequence + + :return: the sequence of the SO line, by default the new one. + """ + return new or old + + def _prepare_invoice_line(self, **optional_values): + """ + Prepare the dict of values to create the new invoice line for a sales order line. + + :param qty: float quantity to invoice + :param optional_values: any parameter that should be added to the returned invoice line + """ + self.ensure_one() + res = { + 'display_type': self.display_type, + 'sequence': self.sequence, + 'name': self.name, + 'product_id': self.product_id.id, + 'product_uom_id': self.product_uom.id, + 'quantity': self.qty_to_invoice, + 'discount': self.discount, + 'price_unit': self.price_unit, + 'tax_ids': [(6, 0, self.tax_id.ids)], + 'analytic_account_id': self.order_id.analytic_account_id.id, + 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], + 'sale_line_ids': [(4, self.id)], + } + if optional_values: + res.update(optional_values) + if self.display_type: + res['account_id'] = False + return res + + def _prepare_procurement_values(self, group_id=False): + """ Prepare specific key for moves or other components that will be created from a stock rule + comming from a sale order line. This method could be override in order to add other custom key that could + be used in move/po creation. + """ + return {} + + def _get_display_price(self, product): + # TO DO: move me in master/saas-16 on sale.order + # awa: don't know if it's still the case since we need the "product_no_variant_attribute_value_ids" field now + # to be able to compute the full price + + # it is possible that a no_variant attribute is still in a variant if + # the type of the attribute has been changed after creation. + no_variant_attributes_price_extra = [ + ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered( + lambda ptav: + ptav.price_extra and + ptav not in product.product_template_attribute_value_ids + ) + ] + if no_variant_attributes_price_extra: + product = product.with_context( + no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra) + ) + + if self.order_id.pricelist_id.discount_policy == 'with_discount': + return product.with_context(pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id).price + product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) + + final_price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(product or self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) + base_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) + if currency != self.order_id.pricelist_id.currency_id: + base_price = currency._convert( + base_price, self.order_id.pricelist_id.currency_id, + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) + # negative discounts (= surcharge) are included in the display price + return max(base_price, final_price) + + @api.onchange('product_id') + def product_id_change(self): + if not self.product_id: + return + valid_values = self.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids + # remove the is_custom values that don't belong to this template + for pacv in self.product_custom_attribute_value_ids: + if pacv.custom_product_template_attribute_value_id not in valid_values: + self.product_custom_attribute_value_ids -= pacv + + # remove the no_variant attributes that don't belong to this template + for ptav in self.product_no_variant_attribute_value_ids: + if ptav._origin not in valid_values: + self.product_no_variant_attribute_value_ids -= ptav + + vals = {} + if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id): + vals['product_uom'] = self.product_id.uom_id + vals['product_uom_qty'] = self.product_uom_qty or 1.0 + + product = self.product_id.with_context( + lang=get_lang(self.env, self.order_id.partner_id.lang).code, + partner=self.order_id.partner_id, + quantity=vals.get('product_uom_qty') or self.product_uom_qty, + date=self.order_id.date_order, + pricelist=self.order_id.pricelist_id.id, + uom=self.product_uom.id + ) + + vals.update(name=self.get_sale_order_line_multiline_description_sale(product)) + + self._compute_tax_id() + + if self.order_id.pricelist_id and self.order_id.partner_id: + vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) + self.update(vals) + + title = False + message = False + result = {} + warning = {} + if product.sale_line_warn != 'no-message': + title = _("Warning for %s", product.name) + message = product.sale_line_warn_msg + warning['title'] = title + warning['message'] = message + result = {'warning': warning} + if product.sale_line_warn == 'block': + self.product_id = False + + return result + + @api.onchange('product_uom', 'product_uom_qty') + def product_uom_change(self): + if not self.product_uom or not self.product_id: + self.price_unit = 0.0 + return + if self.order_id.pricelist_id and self.order_id.partner_id: + product = self.product_id.with_context( + lang=self.order_id.partner_id.lang, + partner=self.order_id.partner_id, + quantity=self.product_uom_qty, + date=self.order_id.date_order, + pricelist=self.order_id.pricelist_id.id, + uom=self.product_uom.id, + fiscal_position=self.env.context.get('fiscal_position') + ) + self.price_unit = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) + + def name_get(self): + result = [] + for so_line in self.sudo(): + name = '%s - %s' % (so_line.order_id.name, so_line.name and so_line.name.split('\n')[0] or so_line.product_id.name) + if so_line.order_partner_id.ref: + name = '%s (%s)' % (name, so_line.order_partner_id.ref) + result.append((so_line.id, name)) + return result + + @api.model + def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): + if operator in ('ilike', 'like', '=', '=like', '=ilike'): + args = expression.AND([ + args or [], + ['|', ('order_id.name', operator, name), ('name', operator, name)] + ]) + return super(SaleOrderLine, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid) + + def _check_line_unlink(self): + """ + Check wether a line can be deleted or not. + + Lines cannot be deleted if the order is confirmed; downpayment + lines who have not yet been invoiced bypass that exception. + :rtype: recordset sale.order.line + :returns: set of lines that cannot be deleted + """ + return self.filtered(lambda line: line.state in ('sale', 'done') and (line.invoice_lines or not line.is_downpayment)) + + @api.ondelete(at_uninstall=False) + def _unlink_except_confirmed(self): + if self._check_line_unlink(): + raise UserError(_('You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.')) + + def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id): + """Retrieve the price before applying the pricelist + :param obj product: object of current product record + :parem float qty: total quentity of product + :param tuple price_and_rule: tuple(price, suitable_rule) coming from pricelist computation + :param obj uom: unit of measure of current order line + :param integer pricelist_id: pricelist id of sales order""" + PricelistItem = self.env['product.pricelist.item'] + field_name = 'lst_price' + currency_id = None + product_currency = product.currency_id + if rule_id: + pricelist_item = PricelistItem.browse(rule_id) + if pricelist_item.pricelist_id.discount_policy == 'without_discount': + while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id and pricelist_item.base_pricelist_id.discount_policy == 'without_discount': + _price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id) + pricelist_item = PricelistItem.browse(rule_id) + + if pricelist_item.base == 'standard_price': + field_name = 'standard_price' + product_currency = product.cost_currency_id + elif pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id: + field_name = 'price' + product = product.with_context(pricelist=pricelist_item.base_pricelist_id.id) + product_currency = pricelist_item.base_pricelist_id.currency_id + currency_id = pricelist_item.pricelist_id.currency_id + + if not currency_id: + currency_id = product_currency + cur_factor = 1.0 + else: + if currency_id.id == product_currency.id: + cur_factor = 1.0 + else: + cur_factor = currency_id._get_conversion_rate(product_currency, currency_id, self.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) + + product_uom = self.env.context.get('uom') or product.uom_id.id + if uom and uom.id != product_uom: + # the unit price is in a different uom + uom_factor = uom._compute_price(1.0, product.uom_id) + else: + uom_factor = 1.0 + + return product[field_name] * uom_factor * cur_factor, currency_id + + def _get_protected_fields(self): + return [ + 'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty', + 'tax_id', 'analytic_tag_ids' + ] + + def _onchange_product_id_set_customer_lead(self): + pass + + @api.onchange('product_id', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id') + def _onchange_discount(self): + if not (self.product_id and self.product_uom and + self.order_id.partner_id and self.order_id.pricelist_id and + self.order_id.pricelist_id.discount_policy == 'without_discount' and + self.env.user.has_group('product.group_discount_per_so_line')): + return + + self.discount = 0.0 + product = self.product_id.with_context( + lang=self.order_id.partner_id.lang, + partner=self.order_id.partner_id, + quantity=self.product_uom_qty, + date=self.order_id.date_order, + pricelist=self.order_id.pricelist_id.id, + uom=self.product_uom.id, + fiscal_position=self.env.context.get('fiscal_position') + ) + + product_context = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order, uom=self.product_uom.id) + + price, rule_id = self.order_id.pricelist_id.with_context(product_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) + new_list_price, currency = self.with_context(product_context)._get_real_price_currency(product, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) + + if new_list_price != 0: + if self.order_id.pricelist_id.currency_id != currency: + # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency + new_list_price = currency._convert( + new_list_price, self.order_id.pricelist_id.currency_id, + self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today()) + discount = (new_list_price - price) / new_list_price * 100 + if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0): + self.discount = discount + + def _is_delivery(self): + self.ensure_one() + return False + + def get_sale_order_line_multiline_description_sale(self, product): + """ Compute a default multiline description for this sales order line. + + In most cases the product description is enough but sometimes we need to append information that only + exists on the sale order line itself. + e.g: + - custom attributes and attributes that don't create variants, both introduced by the "product configurator" + - in event_sale we need to know specifically the sales order line as well as the product to generate the name: + the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line). + """ + return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants() + + def _get_sale_order_line_multiline_description_variants(self): + """When using no_variant attributes or is_custom values, the product + itself is not sufficient to create the description: we need to add + information about those special attributes and values. + + :return: the description related to special variant attributes/values + :rtype: string + """ + if not self.product_custom_attribute_value_ids and not self.product_no_variant_attribute_value_ids: + return "" + + name = "\n" + + custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id + no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin + + # display the no_variant attributes, except those that are also + # displayed by a custom (avoid duplicate description) + for ptav in (no_variant_ptavs - custom_ptavs): + name += "\n" + ptav.with_context(lang=self.order_id.partner_id.lang).display_name + + # Sort the values according to _order settings, because it doesn't work for virtual records in onchange + custom_values = sorted(self.product_custom_attribute_value_ids, key=lambda r: (r.custom_product_template_attribute_value_id.id, r.id)) + # display the is_custom values + for pacv in custom_values: + name += "\n" + pacv.with_context(lang=self.order_id.partner_id.lang).display_name + + return name + + def _is_not_sellable_line(self): + # True if the line is a computed line (reward, delivery, ...) that user cannot add manually + return False diff --git a/addons/sale/models/sales_team.py b/addons/sale/models/sales_team.py index b73828684cc..3dacbc8f9c2 100644 --- a/addons/sale/models/sales_team.py +++ b/addons/sale/models/sales_team.py @@ -3,7 +3,7 @@ from datetime import date -from odoo import api, fields, models, _ +from odoo import fields, models, _ class CrmTeam(models.Model): diff --git a/addons/sale/models/utm.py b/addons/sale/models/utm_campaign.py similarity index 98% rename from addons/sale/models/utm.py rename to addons/sale/models/utm_campaign.py index 1eec837aa90..902856e8788 100644 --- a/addons/sale/models/utm.py +++ b/addons/sale/models/utm_campaign.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import fields, models, api, SUPERUSER_ID +from odoo import fields, models class UtmCampaign(models.Model): _inherit = 'utm.campaign' diff --git a/addons/sale/tests/test_payment_flow.py b/addons/sale/tests/test_payment_flow.py index 406b1fe7456..54693c6380d 100644 --- a/addons/sale/tests/test_payment_flow.py +++ b/addons/sale/tests/test_payment_flow.py @@ -117,7 +117,7 @@ class TestSalePayment(PaymentCommon, PaymentHttpCommon): self.assertEqual(tx_sudo.reference, self.reference) tx_sudo._set_done() - with mute_logger('odoo.addons.sale.models.payment'): + with mute_logger('odoo.addons.sale.models.payment_transaction'): tx_sudo._finalize_post_processing() self.assertEqual(self.order.state, 'draft') # Only a partial amount was paid diff --git a/addons/sale/wizard/__init__.py b/addons/sale/wizard/__init__.py index b0b5d57194d..40ded784140 100644 --- a/addons/sale/wizard/__init__.py +++ b/addons/sale/wizard/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import mail_compose_message from . import payment_acquirer_onboarding_wizard from . import sale_make_invoice_advance from . import sale_order_cancel diff --git a/addons/sale/models/mail_compose_message.py b/addons/sale/wizard/mail_compose_message.py similarity index 100% rename from addons/sale/models/mail_compose_message.py rename to addons/sale/wizard/mail_compose_message.py