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.") + "
"
- for line in order_lines:
- msg += "- %s:
" % line.product_id.display_name
- msg += _(
- "Ordered Quantity: %(old_qty)s -> %(new_qty)s",
- old_qty=line.product_uom_qty,
- new_qty=values["product_uom_qty"]
- ) + "
"
- if line.product_id.type in ('consu', 'product'):
- msg += _("Delivered Quantity: %s", line.qty_delivered) + "
"
- msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "
"
- msg += "
"
- 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.") + ""
+ for line in order_lines:
+ msg += "- %s:
" % line.product_id.display_name
+ msg += _(
+ "Ordered Quantity: %(old_qty)s -> %(new_qty)s",
+ old_qty=line.product_uom_qty,
+ new_qty=values["product_uom_qty"]
+ ) + "
"
+ if line.product_id.type in ('consu', 'product'):
+ msg += _("Delivered Quantity: %s", line.qty_delivered) + "
"
+ msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "
"
+ msg += "
"
+ 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