summaryrefslogtreecommitdiff
path: root/addons/sale/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/sale/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/sale/models')
-rw-r--r--addons/sale/models/__init__.py16
-rw-r--r--addons/sale/models/account_invoice.py105
-rw-r--r--addons/sale/models/account_move.py234
-rw-r--r--addons/sale/models/analytic.py16
-rw-r--r--addons/sale/models/mail_compose_message.py13
-rw-r--r--addons/sale/models/payment.py179
-rw-r--r--addons/sale/models/product_product.py73
-rw-r--r--addons/sale/models/product_template.py294
-rw-r--r--addons/sale/models/res_company.py122
-rw-r--r--addons/sale/models/res_config_settings.py82
-rw-r--r--addons/sale/models/res_partner.py45
-rw-r--r--addons/sale/models/sale.py1911
-rw-r--r--addons/sale/models/sales_team.py137
-rw-r--r--addons/sale/models/utm.py71
14 files changed, 3298 insertions, 0 deletions
diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py
new file mode 100644
index 00000000..92e9953d
--- /dev/null
+++ b/addons/sale/models/__init__.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# 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 product_product
+from . import product_template
+from . import res_company
+from . import res_partner
+from . import sale
+from . import res_config_settings
+from . import sales_team
+from . import payment
+from . import utm
+from . import mail_compose_message
diff --git a/addons/sale/models/account_invoice.py b/addons/sale/models/account_invoice.py
new file mode 100644
index 00000000..9ffe13e5
--- /dev/null
+++ b/addons/sale/models/account_invoice.py
@@ -0,0 +1,105 @@
+# -*- 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
new file mode 100644
index 00000000..3e2f588a
--- /dev/null
+++ b/addons/sale/models/account_move.py
@@ -0,0 +1,234 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+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'
+
+ def action_post(self):
+ #inherit of the function from account.move to validate a new tax and the priceunit of a downpayment
+ res = super(AccountMove, self).action_post()
+ line_ids = self.mapped('line_ids').filtered(lambda line: line.sale_line_ids.is_downpayment)
+ for line in line_ids:
+ try:
+ line.sale_line_ids.tax_id = line.tax_ids
+ if all(line.tax_ids.mapped('price_include')):
+ line.sale_line_ids.price_unit = line.price_unit
+ else:
+ #To keep positive amount on the sale order and to have the right price for the invoice
+ #We need the - before our untaxed_amount_to_invoice
+ line.sale_line_ids.price_unit = -line.sale_line_ids.untaxed_amount_to_invoice
+ except UserError:
+ # a UserError here means the SO was locked, which prevents changing the taxes
+ # just ignore the error - this is a nice to have feature and should not be blocking
+ pass
+ return res
+
+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)
+ for sol in new_sale_lines:
+ if sol.product_id.expense_policy != 'cost':
+ sol._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':
+ product = 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,
+ quantity=unit_amount
+ )
+ if order.pricelist_id.discount_policy == 'with_discount':
+ return product.price
+ return product.lst_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/analytic.py b/addons/sale/models/analytic.py
new file mode 100644
index 00000000..f64a1c1b
--- /dev/null
+++ b/addons/sale/models/analytic.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class AccountAnalyticLine(models.Model):
+ _inherit = "account.analytic.line"
+
+ def _default_sale_line_domain(self):
+ """ This is only used for delivered quantity of SO line based on analytic line, and timesheet
+ (see sale_timesheet). This can be override to allow further customization.
+ """
+ return [('qty_delivered_method', '=', 'analytic')]
+
+ so_line = fields.Many2one('sale.order.line', string='Sales Order Item', domain=lambda self: self._default_sale_line_domain())
diff --git a/addons/sale/models/mail_compose_message.py b/addons/sale/models/mail_compose_message.py
new file mode 100644
index 00000000..1b06a23c
--- /dev/null
+++ b/addons/sale/models/mail_compose_message.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class MailComposeMessage(models.TransientModel):
+ _inherit = 'mail.compose.message'
+
+ def send_mail(self, auto_commit=False):
+ if self.env.context.get('mark_so_as_sent') and self.model == 'sale.order':
+ self = self.with_context(mail_notify_author=self.env.user.partner_id in self.partner_ids)
+ return super(MailComposeMessage, self).send_mail(auto_commit=auto_commit)
diff --git a/addons/sale/models/payment.py b/addons/sale/models/payment.py
new file mode 100644
index 00000000..1d6b8a0a
--- /dev/null
+++ b/addons/sale/models/payment.py
@@ -0,0 +1,179 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+import re
+
+from odoo import api, fields, models, _, SUPERUSER_ID
+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'
+
+ sale_order_ids = fields.Many2many('sale.order', 'sale_order_transaction_rel', 'transaction_id', 'sale_order_id',
+ string='Sales Orders', copy=False, readonly=True)
+ sale_order_ids_nbr = fields.Integer(compute='_compute_sale_order_ids_nbr', string='# of Sales Orders')
+
+ def _compute_sale_order_reference(self, order):
+ self.ensure_one()
+ if self.acquirer_id.so_reference_type == 'so_name':
+ return order.name
+ else:
+ # self.acquirer_id.so_reference_type == 'partner'
+ identification_number = order.partner_id.id
+ return '%s/%s' % ('CUST', str(identification_number % 97).rjust(2, '0'))
+
+ @api.depends('sale_order_ids')
+ def _compute_sale_order_ids_nbr(self):
+ for trans in self:
+ trans.sale_order_ids_nbr = len(trans.sale_order_ids)
+
+ def _log_payment_transaction_sent(self):
+ super(PaymentTransaction, self)._log_payment_transaction_sent()
+ for trans in self:
+ post_message = trans._get_payment_transaction_sent_message()
+ for so in trans.sale_order_ids:
+ so.message_post(body=post_message)
+
+ def _log_payment_transaction_received(self):
+ super(PaymentTransaction, self)._log_payment_transaction_received()
+ for trans in self.filtered(lambda t: t.provider not in ('manual', 'transfer')):
+ post_message = trans._get_payment_transaction_received_message()
+ for so in trans.sale_order_ids:
+ so.message_post(body=post_message)
+
+ def _set_transaction_pending(self):
+ # Override of '_set_transaction_pending' in the 'payment' module
+ # to sent the quotations automatically.
+ super(PaymentTransaction, self)._set_transaction_pending()
+
+ for record in self:
+ sales_orders = record.sale_order_ids.filtered(lambda so: so.state in ['draft', 'sent'])
+ sales_orders.filtered(lambda so: so.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'})
+
+ if record.acquirer_id.provider == 'transfer':
+ for so in record.sale_order_ids:
+ so.reference = record._compute_sale_order_reference(so)
+ # send order confirmation mail
+ sales_orders._send_order_confirmation_mail()
+
+ def _check_amount_and_confirm_order(self):
+ self.ensure_one()
+ for order in self.sale_order_ids.filtered(lambda so: so.state in ('draft', 'sent')):
+ if order.currency_id.compare_amounts(self.amount, order.amount_total) == 0:
+ order.with_context(send_email=True).action_confirm()
+ else:
+ _logger.warning(
+ '<%s> transaction AMOUNT MISMATCH for order %s (ID %s): expected %r, got %r',
+ self.acquirer_id.provider,order.name, order.id,
+ order.amount_total, self.amount,
+ )
+ order.message_post(
+ subject=_("Amount Mismatch (%s)", self.acquirer_id.provider),
+ body=_("The order was not confirmed despite response from the acquirer (%s): order total is %r but acquirer replied with %r.") % (
+ self.acquirer_id.provider,
+ order.amount_total,
+ self.amount,
+ )
+ )
+
+ def _set_transaction_authorized(self):
+ # Override of '_set_transaction_authorized' in the 'payment' module
+ # to confirm the quotations automatically.
+ super(PaymentTransaction, self)._set_transaction_authorized()
+ sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent'))
+ for tx in self:
+ tx._check_amount_and_confirm_order()
+
+ # send order confirmation mail
+ sales_orders._send_order_confirmation_mail()
+
+ def _reconcile_after_transaction_done(self):
+ # Override of '_set_transaction_done' in the 'payment' module
+ # to confirm the quotations automatically and to generate the invoices if needed.
+ sales_orders = self.mapped('sale_order_ids').filtered(lambda so: so.state in ('draft', 'sent'))
+ for tx in self:
+ tx._check_amount_and_confirm_order()
+ # send order confirmation mail
+ sales_orders._send_order_confirmation_mail()
+ # invoice the sale orders if needed
+ self._invoice_sale_orders()
+ res = super(PaymentTransaction, self)._reconcile_after_transaction_done()
+ if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'):
+ default_template = self.env['ir.config_parameter'].sudo().get_param('sale.default_email_template')
+ if default_template:
+ for trans in self.filtered(lambda t: t.sale_order_ids):
+ trans = trans.with_company(trans.acquirer_id.company_id).with_context(
+ mark_invoice_as_sent=True,
+ company_id=trans.acquirer_id.company_id.id,
+ )
+ for invoice in trans.invoice_ids.with_user(SUPERUSER_ID):
+ invoice.message_post_with_template(int(default_template), email_layout_xmlid="mail.mail_notification_paynow")
+ return res
+
+ def _invoice_sale_orders(self):
+ if self.env['ir.config_parameter'].sudo().get_param('sale.automatic_invoice'):
+ for trans in self.filtered(lambda t: t.sale_order_ids):
+ trans = trans.with_company(trans.acquirer_id.company_id)\
+ .with_context(company_id=trans.acquirer_id.company_id.id)
+ trans.sale_order_ids._force_lines_to_invoice_policy_order()
+ invoices = trans.sale_order_ids._create_invoices()
+ trans.invoice_ids = [(6, 0, invoices.ids)]
+
+ @api.model
+ def _compute_reference_prefix(self, values):
+ prefix = super(PaymentTransaction, self)._compute_reference_prefix(values)
+ if not prefix and values and values.get('sale_order_ids'):
+ sale_orders = self.new({'sale_order_ids': values['sale_order_ids']}).sale_order_ids
+ return ','.join(sale_orders.mapped('name'))
+ return prefix
+
+ def action_view_sales_orders(self):
+ action = {
+ 'name': _('Sales Order(s)'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'sale.order',
+ 'target': 'current',
+ }
+ sale_order_ids = self.sale_order_ids.ids
+ if len(sale_order_ids) == 1:
+ action['res_id'] = sale_order_ids[0]
+ action['view_mode'] = 'form'
+ else:
+ action['view_mode'] = 'tree,form'
+ action['domain'] = [('id', 'in', sale_order_ids)]
+ return action
+
+ # --------------------------------------------------
+ # Tools for payment
+ # --------------------------------------------------
+
+ def render_sale_button(self, order, submit_txt=None, render_values=None):
+ values = {
+ 'partner_id': order.partner_id.id,
+ 'type': self.type,
+ }
+ if render_values:
+ values.update(render_values)
+ # Not very elegant to do that here but no choice regarding the design.
+ self._log_payment_transaction_sent()
+ return self.acquirer_id.with_context(submit_class='btn btn-primary', submit_txt=submit_txt or _('Pay Now')).sudo().render(
+ self.reference,
+ order.amount_total,
+ order.pricelist_id.currency_id.id,
+ values=values,
+ )
diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py
new file mode 100644
index 00000000..3e20daa9
--- /dev/null
+++ b/addons/sale/models/product_product.py
@@ -0,0 +1,73 @@
+# -*- 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.tools.float_utils import float_round
+
+
+class ProductProduct(models.Model):
+ _inherit = 'product.product'
+
+ sales_count = fields.Float(compute='_compute_sales_count', string='Sold')
+
+ def _compute_sales_count(self):
+ r = {}
+ self.sales_count = 0
+ if not self.user_has_groups('sales_team.group_sale_salesman'):
+ return r
+ date_from = fields.Datetime.to_string(fields.datetime.combine(fields.datetime.now() - timedelta(days=365),
+ time.min))
+
+ done_states = self.env['sale.report']._get_done_states()
+
+ domain = [
+ ('state', 'in', done_states),
+ ('product_id', 'in', self.ids),
+ ('date', '>=', date_from),
+ ]
+ for group in self.env['sale.report'].read_group(domain, ['product_id', 'product_uom_qty'], ['product_id']):
+ r[group['product_id'][0]] = group['product_uom_qty']
+ for product in self:
+ if not product.id:
+ product.sales_count = 0.0
+ continue
+ product.sales_count = float_round(r.get(product.id, 0), precision_rounding=product.uom_id.rounding)
+ return r
+
+ def action_view_sales(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
+ action['domain'] = [('product_id', 'in', self.ids)]
+ action['context'] = {
+ 'pivot_measures': ['product_uom_qty'],
+ 'active_id': self._context.get('active_id'),
+ 'search_default_Sales': 1,
+ 'active_model': 'sale.report',
+ 'time_ranges': {'field': 'date', 'range': 'last_365_days'},
+ }
+ return action
+
+ def _get_invoice_policy(self):
+ return self.invoice_policy
+
+ def _get_combination_info_variant(self, add_qty=1, pricelist=False, parent_combination=False):
+ """Return the variant info based on its combination.
+ See `_get_combination_info` for more information.
+ """
+ self.ensure_one()
+ return self.product_tmpl_id._get_combination_info(self.product_template_attribute_value_ids, self.id, add_qty, pricelist, parent_combination)
+
+ def _filter_to_unlink(self):
+ domain = [('product_id', 'in', self.ids)]
+ lines = self.env['sale.order.line'].read_group(domain, ['product_id'], ['product_id'])
+ linked_product_ids = [group['product_id'][0] for group in lines]
+ return super(ProductProduct, self - self.browse(linked_product_ids))._filter_to_unlink()
+
+
+class ProductAttributeCustomValue(models.Model):
+ _inherit = "product.attribute.custom.value"
+
+ sale_order_line_id = fields.Many2one('sale.order.line', string="Sales Order Line", required=True, ondelete='cascade')
+
+ _sql_constraints = [
+ ('sol_custom_value_unique', 'unique(custom_product_template_attribute_value_id, sale_order_line_id)', "Only one Custom Value is allowed per Attribute Value per Sales Order Line.")
+ ]
diff --git a/addons/sale/models/product_template.py b/addons/sale/models/product_template.py
new file mode 100644
index 00000000..25c6aaa1
--- /dev/null
+++ b/addons/sale/models/product_template.py
@@ -0,0 +1,294 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+import logging
+
+from odoo import api, fields, models, _
+from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
+from odoo.exceptions import ValidationError
+from odoo.tools.float_utils import float_round
+
+_logger = logging.getLogger(__name__)
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ def _default_visible_expense_policy(self):
+ return self.user_has_groups('analytic.group_analytic_accounting')
+
+ service_type = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service',
+ help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
+ "Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
+ "Create a task and track hours: Create a task on the sales order validation and track the work hours.",
+ default='manual')
+ sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
+ sale_line_warn_msg = fields.Text('Message for Sales Order Line')
+ expense_policy = fields.Selection(
+ [('no', 'No'), ('cost', 'At cost'), ('sales_price', 'Sales price')],
+ string='Re-Invoice Expenses',
+ default='no',
+ help="Expenses and vendor bills can be re-invoiced to a customer."
+ "With this option, a validated expense can be re-invoice to a customer at its cost or sales price.")
+ visible_expense_policy = fields.Boolean("Re-Invoice Policy visible", compute='_compute_visible_expense_policy', default=lambda self: self._default_visible_expense_policy())
+ sales_count = fields.Float(compute='_compute_sales_count', string='Sold')
+ visible_qty_configurator = fields.Boolean("Quantity visible in configurator", compute='_compute_visible_qty_configurator')
+ invoice_policy = fields.Selection([
+ ('order', 'Ordered quantities'),
+ ('delivery', 'Delivered quantities')], string='Invoicing Policy',
+ help='Ordered Quantity: Invoice quantities ordered by the customer.\n'
+ 'Delivered Quantity: Invoice quantities delivered to the customer.',
+ default='order')
+
+ def _compute_visible_qty_configurator(self):
+ for product_template in self:
+ product_template.visible_qty_configurator = True
+
+ @api.depends('name')
+ def _compute_visible_expense_policy(self):
+ visibility = self.user_has_groups('analytic.group_analytic_accounting')
+ for product_template in self:
+ product_template.visible_expense_policy = visibility
+
+
+ @api.onchange('sale_ok')
+ def _change_sale_ok(self):
+ if not self.sale_ok:
+ self.expense_policy = 'no'
+
+ @api.depends('product_variant_ids.sales_count')
+ def _compute_sales_count(self):
+ for product in self:
+ product.sales_count = float_round(sum([p.sales_count for p in product.with_context(active_test=False).product_variant_ids]), precision_rounding=product.uom_id.rounding)
+
+
+ @api.constrains('company_id')
+ def _check_sale_product_company(self):
+ """Ensure the product is not being restricted to a single company while
+ having been sold in another one in the past, as this could cause issues."""
+ target_company = self.company_id
+ if target_company: # don't prevent writing `False`, should always work
+ product_data = self.env['product.product'].sudo().with_context(active_test=False).search_read([('product_tmpl_id', 'in', self.ids)], fields=['id'])
+ product_ids = list(map(lambda p: p['id'], product_data))
+ so_lines = self.env['sale.order.line'].sudo().search_read([('product_id', 'in', product_ids), ('company_id', '!=', target_company.id)], fields=['id', 'product_id'])
+ used_products = list(map(lambda sol: sol['product_id'][1], so_lines))
+ if so_lines:
+ raise ValidationError(_('The following products cannot be restricted to the company'
+ ' %s because they have already been used in quotations or '
+ 'sales orders in another company:\n%s\n'
+ 'You can archive these products and recreate them '
+ 'with your company restriction instead, or leave them as '
+ 'shared product.') % (target_company.name, ', '.join(used_products)))
+
+ def action_view_sales(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("sale.report_all_channels_sales_action")
+ action['domain'] = [('product_tmpl_id', 'in', self.ids)]
+ action['context'] = {
+ 'pivot_measures': ['product_uom_qty'],
+ 'active_id': self._context.get('active_id'),
+ 'active_model': 'sale.report',
+ 'search_default_Sales': 1,
+ 'time_ranges': {'field': 'date', 'range': 'last_365_days'}
+ }
+ return action
+
+ def create_product_variant(self, product_template_attribute_value_ids):
+ """ Create if necessary and possible and return the id of the product
+ variant matching the given combination for this template.
+
+ Note AWA: Known "exploit" issues with this method:
+
+ - This method could be used by an unauthenticated user to generate a
+ lot of useless variants. Unfortunately, after discussing the
+ matter with ODO, there's no easy and user-friendly way to block
+ that behavior.
+
+ We would have to use captcha/server actions to clean/... that
+ are all not user-friendly/overkill mechanisms.
+
+ - This method could be used to try to guess what product variant ids
+ are created in the system and what product template ids are
+ configured as "dynamic", but that does not seem like a big deal.
+
+ The error messages are identical on purpose to avoid giving too much
+ information to a potential attacker:
+ - returning 0 when failing
+ - returning the variant id whether it already existed or not
+
+ :param product_template_attribute_value_ids: the combination for which
+ to get or create variant
+ :type product_template_attribute_value_ids: json encoded list of id
+ of `product.template.attribute.value`
+
+ :return: id of the product variant matching the combination or 0
+ :rtype: int
+ """
+ combination = self.env['product.template.attribute.value'] \
+ .browse(json.loads(product_template_attribute_value_ids))
+
+ return self._create_product_variant(combination, log_warning=True).id or 0
+
+ @api.onchange('type')
+ def _onchange_type(self):
+ """ Force values to stay consistent with integrity constraints """
+ res = super(ProductTemplate, self)._onchange_type()
+ if self.type == 'consu':
+ if not self.invoice_policy:
+ self.invoice_policy = 'order'
+ self.service_type = 'manual'
+ return res
+
+ @api.model
+ def get_import_templates(self):
+ res = super(ProductTemplate, self).get_import_templates()
+ if self.env.context.get('sale_multi_pricelist_product_template'):
+ if self.user_has_groups('product.group_sale_pricelist'):
+ return [{
+ 'label': _('Import Template for Products'),
+ 'template': '/product/static/xls/product_template.xls'
+ }]
+ return res
+
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
+ """ Return info about a given combination.
+
+ Note: this method does not take into account whether the combination is
+ actually possible.
+
+ :param combination: recordset of `product.template.attribute.value`
+
+ :param product_id: id of a `product.product`. If no `combination`
+ is set, the method will try to load the variant `product_id` if
+ it exists instead of finding a variant based on the combination.
+
+ If there is no combination, that means we definitely want a
+ variant and not something that will have no_variant set.
+
+ :param add_qty: float with the quantity for which to get the info,
+ indeed some pricelist rules might depend on it.
+
+ :param pricelist: `product.pricelist` the pricelist to use
+ (can be none, eg. from SO if no partner and no pricelist selected)
+
+ :param parent_combination: if no combination and no product_id are
+ given, it will try to find the first possible combination, taking
+ into account parent_combination (if set) for the exclusion rules.
+
+ :param only_template: boolean, if set to True, get the info for the
+ template only: ignore combination and don't try to find variant
+
+ :return: dict with product/combination info:
+
+ - product_id: the variant id matching the combination (if it exists)
+
+ - product_template_id: the current template id
+
+ - display_name: the name of the combination
+
+ - price: the computed price of the combination, take the catalog
+ price if no pricelist is given
+
+ - list_price: the catalog price of the combination, but this is
+ not the "real" list_price, it has price_extra included (so
+ it's actually more closely related to `lst_price`), and it
+ is converted to the pricelist currency (if given)
+
+ - has_discounted_price: True if the pricelist discount policy says
+ the price does not include the discount and there is actually a
+ discount applied (price < list_price), else False
+ """
+ self.ensure_one()
+ # get the name before the change of context to benefit from prefetch
+ display_name = self.display_name
+
+ display_image = True
+ quantity = self.env.context.get('quantity', add_qty)
+ context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
+ product_template = self.with_context(context)
+
+ combination = combination or product_template.env['product.template.attribute.value']
+
+ if not product_id and not combination and not only_template:
+ combination = product_template._get_first_possible_combination(parent_combination)
+
+ if only_template:
+ product = product_template.env['product.product']
+ elif product_id and not combination:
+ product = product_template.env['product.product'].browse(product_id)
+ else:
+ product = product_template._get_variant_for_combination(combination)
+
+ if product:
+ # We need to add the price_extra for the attributes that are not
+ # in the variant, typically those of type no_variant, but 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 combination.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)
+ )
+ list_price = product.price_compute('list_price')[product.id]
+ price = product.price if pricelist else list_price
+ display_image = bool(product.image_1920)
+ display_name = product.display_name
+ else:
+ product_template = product_template.with_context(current_attributes_price_extra=[v.price_extra or 0.0 for v in combination])
+ list_price = product_template.price_compute('list_price')[product_template.id]
+ price = product_template.price if pricelist else list_price
+ display_image = bool(product_template.image_1920)
+
+ combination_name = combination._get_combination_name()
+ if combination_name:
+ display_name = "%s (%s)" % (display_name, combination_name)
+
+ if pricelist and pricelist.currency_id != product_template.currency_id:
+ list_price = product_template.currency_id._convert(
+ list_price, pricelist.currency_id, product_template._get_current_company(pricelist=pricelist),
+ fields.Date.today()
+ )
+
+ price_without_discount = list_price if pricelist and pricelist.discount_policy == 'without_discount' else price
+ has_discounted_price = (pricelist or product_template).currency_id.compare_amounts(price_without_discount, price) == 1
+
+ return {
+ 'product_id': product.id,
+ 'product_template_id': product_template.id,
+ 'display_name': display_name,
+ 'display_image': display_image,
+ 'price': price,
+ 'list_price': list_price,
+ 'has_discounted_price': has_discounted_price,
+ }
+
+ def _is_add_to_cart_possible(self, parent_combination=None):
+ """
+ It's possible to add to cart (potentially after configuration) if
+ there is at least one possible combination.
+
+ :param parent_combination: the combination from which `self` is an
+ optional or accessory product.
+ :type parent_combination: recordset `product.template.attribute.value`
+
+ :return: True if it's possible to add to cart, else False
+ :rtype: bool
+ """
+ self.ensure_one()
+ if not self.active:
+ # for performance: avoid calling `_get_possible_combinations`
+ return False
+ return next(self._get_possible_combinations(parent_combination), False) is not False
+
+ def _get_current_company_fallback(self, **kwargs):
+ """Override: if a pricelist is given, fallback to the company of the
+ pricelist if it is set, otherwise use the one from parent method."""
+ res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
+ pricelist = kwargs.get('pricelist')
+ return pricelist and pricelist.company_id or res
diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py
new file mode 100644
index 00000000..e5f70e2e
--- /dev/null
+++ b/addons/sale/models/res_company.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+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"
+
+ portal_confirmation_sign = fields.Boolean(string='Online Signature', default=True)
+ portal_confirmation_pay = fields.Boolean(string='Online Payment')
+ quotation_validity_days = fields.Integer(default=30, string="Default Quotation Validity (Days)")
+
+ # sale quotation onboarding
+ sale_quotation_onboarding_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done"), ('closed', "Closed")], string="State of the sale onboarding panel", default='not_done')
+ sale_onboarding_order_confirmation_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding confirmation order step", default='not_done')
+ sale_onboarding_sample_quotation_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding sample quotation step", default='not_done')
+
+ sale_onboarding_payment_method = fields.Selection([
+ ('digital_signature', 'Sign online'),
+ ('paypal', 'PayPal'),
+ ('stripe', 'Stripe'),
+ ('other', 'Pay with another payment acquirer'),
+ ('manual', 'Manual Payment'),
+ ], string="Sale onboarding selected payment method")
+
+ @api.model
+ def action_close_sale_quotation_onboarding(self):
+ """ Mark the onboarding panel as closed. """
+ self.env.company.sale_quotation_onboarding_state = 'closed'
+
+ @api.model
+ def action_open_sale_onboarding_payment_acquirer(self):
+ """ Called by onboarding panel above the quotation list."""
+ self.env.company.get_chart_of_accounts_or_fail()
+ action = self.env["ir.actions.actions"]._for_xml_id("sale.action_open_sale_onboarding_payment_acquirer_wizard")
+ return action
+
+ def _get_sample_sales_order(self):
+ """ Get a sample quotation or create one if it does not exist. """
+ # use current user as partner
+ partner = self.env.user.partner_id
+ company_id = self.env.company.id
+ # is there already one?
+ sample_sales_order = self.env['sale.order'].search(
+ [('company_id', '=', company_id), ('partner_id', '=', partner.id),
+ ('state', '=', 'draft')], limit=1)
+ if len(sample_sales_order) == 0:
+ sample_sales_order = self.env['sale.order'].create({
+ 'partner_id': partner.id
+ })
+ # take any existing product or create one
+ product = self.env['product.product'].search([], limit=1)
+ if len(product) == 0:
+ default_image_path = get_module_resource('product', 'static/img', 'product_product_13-image.png')
+ product = self.env['product.product'].create({
+ 'name': _('Sample Product'),
+ 'active': False,
+ 'image_1920': base64.b64encode(open(default_image_path, 'rb').read())
+ })
+ product.product_tmpl_id.write({'active': False})
+ self.env['sale.order.line'].create({
+ 'name': _('Sample Order Line'),
+ 'product_id': product.id,
+ 'product_uom_qty': 10,
+ 'price_unit': 123,
+ 'order_id': sample_sales_order.id,
+ 'company_id': sample_sales_order.company_id.id,
+ })
+ return sample_sales_order
+
+ @api.model
+ def action_open_sale_onboarding_sample_quotation(self):
+ """ Onboarding step for sending a sample quotation. Open a window to compose an email,
+ with the edi_invoice_template message loaded by default. """
+ sample_sales_order = self._get_sample_sales_order()
+ template = self.env.ref('sale.email_template_edi_sale', False)
+
+ message_composer = self.env['mail.compose.message'].with_context(
+ default_use_template=bool(template),
+ mark_so_as_sent=True,
+ custom_layout='mail.mail_notification_paynow',
+ proforma=self.env.context.get('proforma', False),
+ force_email=True, mail_notify_author=True
+ ).create({
+ 'res_id': sample_sales_order.id,
+ 'template_id': template and template.id or False,
+ 'model': 'sale.order',
+ 'composition_mode': 'comment'})
+
+ # Simulate the onchange (like trigger in form the view)
+ update_values = message_composer.onchange_template_id(template.id, 'comment', 'sale.order', sample_sales_order.id)['value']
+ message_composer.write(update_values)
+
+ message_composer.send_mail()
+
+ self.set_onboarding_step_done('sale_onboarding_sample_quotation_state')
+
+ self.action_close_sale_quotation_onboarding()
+
+ action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders")
+ action.update({
+ 'views': [[self.env.ref('sale.view_order_form').id, 'form']],
+ 'view_mode': 'form',
+ 'target': 'main',
+ })
+ return action
+
+ def get_and_update_sale_quotation_onboarding_state(self):
+ """ This method is called on the controller rendering method and ensures that the animations
+ are displayed only one time. """
+ steps = [
+ 'base_onboarding_company_state',
+ 'account_onboarding_invoice_layout_state',
+ 'sale_onboarding_order_confirmation_state',
+ 'sale_onboarding_sample_quotation_state',
+ ]
+ return self.get_and_update_onbarding_state('sale_quotation_onboarding_state', steps)
+
+ _sql_constraints = [('check_quotation_validity_days', 'CHECK(quotation_validity_days > 0)', 'Quotation Validity is required and must be greater than 0.')]
diff --git a/addons/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py
new file mode 100644
index 00000000..61620dcd
--- /dev/null
+++ b/addons/sale/models/res_config_settings.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ group_auto_done_setting = fields.Boolean("Lock Confirmed Sales", implied_group='sale.group_auto_done_setting')
+ module_sale_margin = fields.Boolean("Margins")
+ quotation_validity_days = fields.Integer(related='company_id.quotation_validity_days', string="Default Quotation Validity (Days)", readonly=False)
+ use_quotation_validity_days = fields.Boolean("Default Quotation Validity", config_parameter='sale.use_quotation_validity_days')
+ group_warning_sale = fields.Boolean("Sale Order Warnings", implied_group='sale.group_warning_sale')
+ portal_confirmation_sign = fields.Boolean(related='company_id.portal_confirmation_sign', string='Online Signature', readonly=False)
+ portal_confirmation_pay = fields.Boolean(related='company_id.portal_confirmation_pay', string='Online Payment', readonly=False)
+ group_sale_delivery_address = fields.Boolean("Customer Addresses", implied_group='sale.group_delivery_invoice_address')
+ group_proforma_sales = fields.Boolean(string="Pro-Forma Invoice", implied_group='sale.group_proforma_sales',
+ help="Allows you to send pro-forma invoice.")
+ default_invoice_policy = fields.Selection([
+ ('order', 'Invoice what is ordered'),
+ ('delivery', 'Invoice what is delivered')
+ ], 'Invoicing Policy',
+ default='delivery',
+ default_model='product.template')
+ deposit_default_product_id = fields.Many2one(
+ 'product.product',
+ 'Deposit Product',
+ domain="[('type', '=', 'service')]",
+ config_parameter='sale.default_deposit_product_id',
+ help='Default product used for payment advances')
+
+ auth_signup_uninvited = fields.Selection([
+ ('b2b', 'On invitation'),
+ ('b2c', 'Free sign up'),
+ ], string='Customer Account', default='b2b', config_parameter='auth_signup.invitation_scope')
+
+ module_delivery = fields.Boolean("Delivery Methods")
+ module_delivery_dhl = fields.Boolean("DHL USA Connector")
+ module_delivery_fedex = fields.Boolean("FedEx Connector")
+ module_delivery_ups = fields.Boolean("UPS Connector")
+ module_delivery_usps = fields.Boolean("USPS Connector")
+ module_delivery_bpost = fields.Boolean("bpost Connector")
+ module_delivery_easypost = fields.Boolean("Easypost Connector")
+
+ module_product_email_template = fields.Boolean("Specific Email")
+ module_sale_coupon = fields.Boolean("Coupons & Promotions")
+ module_sale_amazon = fields.Boolean("Amazon Sync")
+
+ automatic_invoice = fields.Boolean("Automatic Invoice",
+ help="The invoice is generated automatically and available in the customer portal "
+ "when the transaction is confirmed by the payment acquirer.\n"
+ "The invoice is marked as paid and the payment is registered in the payment journal "
+ "defined in the configuration of the payment acquirer.\n"
+ "This mode is advised if you issue the final invoice at the order and not after the delivery.",
+ config_parameter='sale.automatic_invoice')
+ template_id = fields.Many2one('mail.template', 'Email Template',
+ domain="[('model', '=', 'account.move')]",
+ config_parameter='sale.default_email_template',
+ default=lambda self: self.env.ref('account.email_template_edi_invoice', False))
+ confirmation_template_id = fields.Many2one('mail.template', string='Confirmation Email',
+ domain="[('model', '=', 'sale.order')]",
+ config_parameter='sale.default_confirmation_template',
+ help="Email sent to the customer once the order is paid.")
+
+ def set_values(self):
+ super(ResConfigSettings, self).set_values()
+ if self.default_invoice_policy != 'order':
+ self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
+
+ @api.onchange('use_quotation_validity_days')
+ def _onchange_use_quotation_validity_days(self):
+ if self.quotation_validity_days <= 0:
+ self.quotation_validity_days = self.env['res.company'].default_get(['quotation_validity_days'])['quotation_validity_days']
+
+ @api.onchange('quotation_validity_days')
+ def _onchange_quotation_validity_days(self):
+ if self.quotation_validity_days <= 0:
+ self.quotation_validity_days = self.env['res.company'].default_get(['quotation_validity_days'])['quotation_validity_days']
+ return {
+ 'warning': {'title': "Warning", 'message': "Quotation Validity is required and must be greater than 0."},
+ }
diff --git a/addons/sale/models/res_partner.py b/addons/sale/models/res_partner.py
new file mode 100644
index 00000000..d77abad7
--- /dev/null
+++ b/addons/sale/models/res_partner.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+from odoo.addons.base.models.res_partner import WARNING_MESSAGE, WARNING_HELP
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ sale_order_count = fields.Integer(compute='_compute_sale_order_count', string='Sale Order Count')
+ sale_order_ids = fields.One2many('sale.order', 'partner_id', 'Sales Order')
+ sale_warn = fields.Selection(WARNING_MESSAGE, 'Sales Warnings', default='no-message', help=WARNING_HELP)
+ sale_warn_msg = fields.Text('Message for Sales Order')
+
+ def _compute_sale_order_count(self):
+ # retrieve all children partners and prefetch 'parent_id' on them
+ all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
+ all_partners.read(['parent_id'])
+
+ sale_order_groups = self.env['sale.order'].read_group(
+ domain=[('partner_id', 'in', all_partners.ids)],
+ fields=['partner_id'], groupby=['partner_id']
+ )
+ partners = self.browse()
+ for group in sale_order_groups:
+ partner = self.browse(group['partner_id'][0])
+ while partner:
+ if partner in self:
+ partner.sale_order_count += group['partner_id_count']
+ partners |= partner
+ partner = partner.parent_id
+ (self - partners).sale_order_count = 0
+
+ def can_edit_vat(self):
+ ''' Can't edit `vat` if there is (non draft) issued SO. '''
+ can_edit_vat = super(ResPartner, self).can_edit_vat()
+ if not can_edit_vat:
+ return can_edit_vat
+ SaleOrder = self.env['sale.order']
+ has_so = SaleOrder.search([
+ ('partner_id', 'child_of', self.commercial_partner_id.id),
+ ('state', 'in', ['sent', 'sale', 'done'])
+ ], limit=1)
+ return can_edit_vat and not bool(has_so)
diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py
new file mode 100644
index 00000000..063b9228
--- /dev/null
+++ b/addons/sale/models/sale.py
@@ -0,0 +1,1911 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, timedelta
+from functools import partial
+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.osv import expression
+from odoo.tools import float_is_zero, float_compare
+
+
+
+from werkzeug.urls import url_encode
+
+
+class SaleOrder(models.Model):
+ _name = "sale.order"
+ _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'utm.mixin']
+ _description = "Sales Order"
+ _order = 'date_order desc, id desc'
+ _check_company_auto = True
+
+ def _default_validity_date(self):
+ if self.env['ir.config_parameter'].sudo().get_param('sale.use_quotation_validity_days'):
+ days = self.env.company.quotation_validity_days
+ if days > 0:
+ return fields.Date.to_string(datetime.now() + timedelta(days))
+ return False
+
+ def _get_default_require_signature(self):
+ return self.env.company.portal_confirmation_sign
+
+ def _get_default_require_payment(self):
+ return self.env.company.portal_confirmation_pay
+
+ @api.depends('order_line.price_total')
+ def _amount_all(self):
+ """
+ Compute the total amounts of the SO.
+ """
+ for order in self:
+ amount_untaxed = amount_tax = 0.0
+ for line in order.order_line:
+ amount_untaxed += line.price_subtotal
+ amount_tax += line.price_tax
+ order.update({
+ 'amount_untaxed': amount_untaxed,
+ 'amount_tax': amount_tax,
+ 'amount_total': amount_untaxed + amount_tax,
+ })
+
+ @api.depends('order_line.invoice_lines')
+ def _get_invoiced(self):
+ # The invoice_ids are obtained thanks to the invoice lines of the SO
+ # lines, and we also search for possible refunds created directly from
+ # existing invoices. This is necessary since such a refund is not
+ # directly linked to the SO.
+ for order in self:
+ invoices = order.order_line.invoice_lines.move_id.filtered(lambda r: r.move_type in ('out_invoice', 'out_refund'))
+ order.invoice_ids = invoices
+ order.invoice_count = len(invoices)
+
+ @api.depends('state', 'order_line.invoice_status')
+ def _get_invoice_status(self):
+ """
+ Compute the invoice status of a SO. Possible statuses:
+ - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to
+ invoice. This is also the default value if the conditions of no other status is met.
+ - to invoice: if any SO line is 'to invoice', the whole SO is 'to invoice'
+ - invoiced: if all SO lines are invoiced, the SO is invoiced.
+ - upselling: if all SO lines are invoiced or upselling, the status is upselling.
+ """
+ unconfirmed_orders = self.filtered(lambda so: so.state not in ['sale', 'done'])
+ unconfirmed_orders.invoice_status = 'no'
+ confirmed_orders = self - unconfirmed_orders
+ if not confirmed_orders:
+ return
+ line_invoice_status_all = [
+ (d['order_id'][0], d['invoice_status'])
+ for d in self.env['sale.order.line'].read_group([
+ ('order_id', 'in', confirmed_orders.ids),
+ ('is_downpayment', '=', False),
+ ('display_type', '=', False),
+ ],
+ ['order_id', 'invoice_status'],
+ ['order_id', 'invoice_status'], lazy=False)]
+ for order in confirmed_orders:
+ line_invoice_status = [d[1] for d in line_invoice_status_all if d[0] == order.id]
+ if order.state not in ('sale', 'done'):
+ order.invoice_status = 'no'
+ elif any(invoice_status == 'to invoice' for invoice_status in line_invoice_status):
+ order.invoice_status = 'to invoice'
+ elif line_invoice_status and all(invoice_status == 'invoiced' for invoice_status in line_invoice_status):
+ order.invoice_status = 'invoiced'
+ elif line_invoice_status and all(invoice_status in ('invoiced', 'upselling') for invoice_status in line_invoice_status):
+ order.invoice_status = 'upselling'
+ else:
+ order.invoice_status = 'no'
+
+ @api.model
+ def get_empty_list_help(self, help):
+ self = self.with_context(
+ empty_list_help_document_name=_("sale order"),
+ )
+ return super(SaleOrder, self).get_empty_list_help(help)
+
+ @api.model
+ def _default_note(self):
+ return self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.company.invoice_terms or ''
+
+ @api.model
+ def _get_default_team(self):
+ return self.env['crm.team']._get_default_team_id()
+
+ @api.onchange('fiscal_position_id')
+ def _compute_tax_id(self):
+ """
+ Trigger the recompute of the taxes if the fiscal position is changed on the SO.
+ """
+ for order in self:
+ order.order_line._compute_tax_id()
+
+ def _search_invoice_ids(self, operator, value):
+ if operator == 'in' and value:
+ self.env.cr.execute("""
+ SELECT array_agg(so.id)
+ FROM sale_order so
+ JOIN sale_order_line sol ON sol.order_id = so.id
+ JOIN sale_order_line_invoice_rel soli_rel ON soli_rel.order_line_id = sol.id
+ JOIN account_move_line aml ON aml.id = soli_rel.invoice_line_id
+ JOIN account_move am ON am.id = aml.move_id
+ WHERE
+ am.move_type in ('out_invoice', 'out_refund') AND
+ am.id = ANY(%s)
+ """, (list(value),))
+ so_ids = self.env.cr.fetchone()[0] or []
+ return [('id', 'in', so_ids)]
+ elif operator == '=' and not value:
+ # special case for [('invoice_ids', '=', False)], i.e. "Invoices is not set"
+ #
+ # We cannot just search [('order_line.invoice_lines', '=', False)]
+ # because it returns orders with uninvoiced lines, which is not
+ # same "Invoices is not set" (some lines may have invoices and some
+ # doesn't)
+ #
+ # A solution is making inverted search first ("orders with invoiced
+ # lines") and then invert results ("get all other orders")
+ #
+ # Domain below returns subset of ('order_line.invoice_lines', '!=', False)
+ order_ids = self._search([
+ ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund'))
+ ])
+ return [('id', 'not in', order_ids)]
+ return ['&', ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')), ('order_line.invoice_lines.move_id', operator, value)]
+
+ name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New'))
+ origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.")
+ client_order_ref = fields.Char(string='Customer Reference', copy=False)
+ reference = fields.Char(string='Payment Ref.', copy=False,
+ help='The payment communication of this sale order.')
+ state = fields.Selection([
+ ('draft', 'Quotation'),
+ ('sent', 'Quotation Sent'),
+ ('sale', 'Sales Order'),
+ ('done', 'Locked'),
+ ('cancel', 'Cancelled'),
+ ], string='Status', readonly=True, copy=False, index=True, tracking=3, default='draft')
+ date_order = fields.Datetime(string='Order Date', required=True, readonly=True, index=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=False, default=fields.Datetime.now, help="Creation date of draft/sent orders,\nConfirmation date of confirmed orders.")
+ validity_date = fields.Date(string='Expiration', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
+ default=_default_validity_date)
+ is_expired = fields.Boolean(compute='_compute_is_expired', string="Is expired")
+ require_signature = fields.Boolean('Online Signature', default=_get_default_require_signature, readonly=True,
+ states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
+ help='Request a online signature to the customer in order to confirm orders automatically.')
+ require_payment = fields.Boolean('Online Payment', default=_get_default_require_payment, readonly=True,
+ states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
+ help='Request an online payment to the customer in order to confirm orders automatically.')
+ create_date = fields.Datetime(string='Creation Date', readonly=True, index=True, help="Date on which sales order is created.")
+
+ user_id = fields.Many2one(
+ 'res.users', string='Salesperson', index=True, tracking=2, default=lambda self: self.env.user,
+ domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman').id)])
+ partner_id = fields.Many2one(
+ 'res.partner', string='Customer', readonly=True,
+ states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
+ required=True, change_default=True, index=True, tracking=1,
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
+ partner_invoice_id = fields.Many2one(
+ 'res.partner', string='Invoice Address',
+ readonly=True, required=True,
+ states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]},
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
+ partner_shipping_id = fields.Many2one(
+ 'res.partner', string='Delivery Address', readonly=True, required=True,
+ states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]},
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
+
+ pricelist_id = fields.Many2one(
+ 'product.pricelist', string='Pricelist', check_company=True, # Unrequired company
+ required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=1,
+ help="If you change the pricelist, only newly added lines will be affected.")
+ currency_id = fields.Many2one(related='pricelist_id.currency_id', depends=["pricelist_id"], store=True)
+ analytic_account_id = fields.Many2one(
+ 'account.analytic.account', 'Analytic Account',
+ readonly=True, copy=False, check_company=True, # Unrequired company
+ states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
+ help="The analytic account related to a sales order.")
+
+ order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True)
+
+ invoice_count = fields.Integer(string='Invoice Count', compute='_get_invoiced', readonly=True)
+ invoice_ids = fields.Many2many("account.move", string='Invoices', compute="_get_invoiced", readonly=True, copy=False, search="_search_invoice_ids")
+ invoice_status = fields.Selection([
+ ('upselling', 'Upselling Opportunity'),
+ ('invoiced', 'Fully Invoiced'),
+ ('to invoice', 'To Invoice'),
+ ('no', 'Nothing to Invoice')
+ ], string='Invoice Status', compute='_get_invoice_status', store=True, readonly=True)
+
+ note = fields.Text('Terms and conditions', default=_default_note)
+
+ amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', tracking=5)
+ amount_by_group = fields.Binary(string="Tax amount by group", compute='_amount_by_group', help="type: [(name, amount, base, formated amount, formated base)]")
+ amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all')
+ amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_amount_all', tracking=4)
+ currency_rate = fields.Float("Currency Rate", compute='_compute_currency_rate', compute_sudo=True, store=True, digits=(12, 6), readonly=True, help='The rate of the currency to the currency of rate 1 applicable at the date of the order')
+
+ payment_term_id = fields.Many2one(
+ 'account.payment.term', string='Payment Terms', check_company=True, # Unrequired company
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
+ fiscal_position_id = fields.Many2one(
+ 'account.fiscal.position', string='Fiscal Position',
+ domain="[('company_id', '=', company_id)]", check_company=True,
+ help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices."
+ "The default value comes from the customer.")
+ company_id = fields.Many2one('res.company', 'Company', required=True, index=True, default=lambda self: self.env.company)
+ team_id = fields.Many2one(
+ 'crm.team', 'Sales Team',
+ change_default=True, default=_get_default_team, check_company=True, # Unrequired company
+ domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+
+ signature = fields.Image('Signature', help='Signature received through the portal.', copy=False, attachment=True, max_width=1024, max_height=1024)
+ signed_by = fields.Char('Signed By', help='Name of the person that signed the SO.', copy=False)
+ signed_on = fields.Datetime('Signed On', help='Date of the signature.', copy=False)
+
+ commitment_date = fields.Datetime('Delivery Date', copy=False,
+ states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
+ help="This is the delivery date promised to the customer. "
+ "If set, the delivery order will be scheduled based on "
+ "this date rather than product lead times.")
+ expected_date = fields.Datetime("Expected Date", compute='_compute_expected_date', store=False, # Note: can not be stored since depends on today()
+ help="Delivery date you can promise to the customer, computed from the minimum lead time of the order lines.")
+ amount_undiscounted = fields.Float('Amount Before Discount', compute='_compute_amount_undiscounted', digits=0)
+
+ type_name = fields.Char('Type Name', compute='_compute_type_name')
+
+ transaction_ids = fields.Many2many('payment.transaction', 'sale_order_transaction_rel', 'sale_order_id', 'transaction_id',
+ string='Transactions', copy=False, readonly=True)
+ authorized_transaction_ids = fields.Many2many('payment.transaction', compute='_compute_authorized_transaction_ids',
+ string='Authorized Transactions', copy=False, readonly=True)
+ show_update_pricelist = fields.Boolean(string='Has Pricelist Changed',
+ help="Technical Field, True if the pricelist was changed;\n"
+ " this will then display a recomputation button")
+ tag_ids = fields.Many2many('crm.tag', 'sale_order_tag_rel', 'order_id', 'tag_id', string='Tags')
+
+ _sql_constraints = [
+ ('date_order_conditional_required', "CHECK( (state IN ('sale', 'done') AND date_order IS NOT NULL) OR state NOT IN ('sale', 'done') )", "A confirmed sales order requires a confirmation date."),
+ ]
+
+ @api.constrains('company_id', 'order_line')
+ def _check_order_line_company_id(self):
+ for order in self:
+ companies = order.order_line.product_id.company_id
+ if companies and companies != order.company_id:
+ bad_products = order.order_line.product_id.filtered(lambda p: p.company_id and p.company_id != order.company_id)
+ raise ValidationError(_(
+ "Your quotation contains products from company %(product_company)s whereas your quotation belongs to company %(quote_company)s. \n Please change the company of your quotation or remove the products from other companies (%(bad_products)s).",
+ product_company=', '.join(companies.mapped('display_name')),
+ quote_company=order.company_id.display_name,
+ bad_products=', '.join(bad_products.mapped('display_name')),
+ ))
+
+ @api.depends('pricelist_id', 'date_order', 'company_id')
+ def _compute_currency_rate(self):
+ for order in self:
+ if not order.company_id:
+ order.currency_rate = order.currency_id.with_context(date=order.date_order).rate or 1.0
+ continue
+ elif order.company_id.currency_id and order.currency_id: # the following crashes if any one is undefined
+ order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order)
+ else:
+ order.currency_rate = 1.0
+
+ def _compute_access_url(self):
+ super(SaleOrder, self)._compute_access_url()
+ for order in self:
+ order.access_url = '/my/orders/%s' % (order.id)
+
+ def _compute_is_expired(self):
+ today = fields.Date.today()
+ for order in self:
+ order.is_expired = order.state == 'sent' and order.validity_date and order.validity_date < today
+
+ @api.depends('order_line.customer_lead', 'date_order', 'order_line.state')
+ def _compute_expected_date(self):
+ """ For service and consumable, we only take the min dates. This method is extended in sale_stock to
+ take the picking_policy of SO into account.
+ """
+ self.mapped("order_line") # Prefetch indication
+ for order in self:
+ dates_list = []
+ for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type):
+ dt = line._expected_date()
+ dates_list.append(dt)
+ if dates_list:
+ order.expected_date = fields.Datetime.to_string(min(dates_list))
+ else:
+ order.expected_date = False
+
+ @api.onchange('expected_date')
+ def _onchange_commitment_date(self):
+ self.commitment_date = self.expected_date
+
+ @api.depends('transaction_ids')
+ def _compute_authorized_transaction_ids(self):
+ for trans in self:
+ trans.authorized_transaction_ids = trans.transaction_ids.filtered(lambda t: t.state == 'authorized')
+
+ def _compute_amount_undiscounted(self):
+ for order in self:
+ total = 0.0
+ for line in order.order_line:
+ total += line.price_subtotal + line.price_unit * ((line.discount or 0.0) / 100.0) * line.product_uom_qty # why is there a discount in a field named amount_undiscounted ??
+ order.amount_undiscounted = total
+
+ @api.depends('state')
+ def _compute_type_name(self):
+ for record in self:
+ record.type_name = _('Quotation') if record.state in ('draft', 'sent', 'cancel') else _('Sales Order')
+
+ def unlink(self):
+ for order in self:
+ if order.state not in ('draft', 'cancel'):
+ raise UserError(_('You can not delete a sent quotation or a confirmed sales order. You must first cancel it.'))
+ return super(SaleOrder, self).unlink()
+
+ def validate_taxes_on_sales_order(self):
+ # Override for correct taxcloud computation
+ # when using coupon and delivery
+ return True
+
+ def _track_subtype(self, init_values):
+ self.ensure_one()
+ if 'state' in init_values and self.state == 'sale':
+ return self.env.ref('sale.mt_order_confirmed')
+ elif 'state' in init_values and self.state == 'sent':
+ return self.env.ref('sale.mt_order_sent')
+ return super(SaleOrder, self)._track_subtype(init_values)
+
+ @api.onchange('partner_shipping_id', 'partner_id', 'company_id')
+ def onchange_partner_shipping_id(self):
+ """
+ Trigger the change of fiscal position when the shipping address is modified.
+ """
+ self.fiscal_position_id = self.env['account.fiscal.position'].with_company(self.company_id).get_fiscal_position(self.partner_id.id, self.partner_shipping_id.id)
+ return {}
+
+ @api.onchange('partner_id')
+ def onchange_partner_id(self):
+ """
+ Update the following fields when the partner is changed:
+ - Pricelist
+ - Payment terms
+ - Invoice address
+ - Delivery address
+ - Sales Team
+ """
+ if not self.partner_id:
+ self.update({
+ 'partner_invoice_id': False,
+ 'partner_shipping_id': False,
+ 'fiscal_position_id': False,
+ })
+ return
+
+ self = self.with_company(self.company_id)
+
+ addr = self.partner_id.address_get(['delivery', 'invoice'])
+ partner_user = self.partner_id.user_id or self.partner_id.commercial_partner_id.user_id
+ values = {
+ 'pricelist_id': self.partner_id.property_product_pricelist and self.partner_id.property_product_pricelist.id or False,
+ 'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False,
+ 'partner_invoice_id': addr['invoice'],
+ 'partner_shipping_id': addr['delivery'],
+ }
+ user_id = partner_user.id
+ if not self.env.context.get('not_self_saleperson'):
+ user_id = user_id or self.env.uid
+ if user_id and self.user_id.id != user_id:
+ values['user_id'] = user_id
+
+ if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.company.invoice_terms:
+ values['note'] = self.with_context(lang=self.partner_id.lang).env.company.invoice_terms
+ if not self.env.context.get('not_self_saleperson') or not self.team_id:
+ values['team_id'] = self.env['crm.team'].with_context(
+ default_team_id=self.partner_id.team_id.id
+ )._get_default_team_id(domain=['|', ('company_id', '=', self.company_id.id), ('company_id', '=', False)], user_id=user_id)
+ self.update(values)
+
+ @api.onchange('user_id')
+ def onchange_user_id(self):
+ if self.user_id:
+ self.team_id = self.env['crm.team'].with_context(
+ default_team_id=self.team_id.id
+ )._get_default_team_id(user_id=self.user_id.id)
+
+ @api.onchange('partner_id')
+ def onchange_partner_id_warning(self):
+ if not self.partner_id:
+ return
+ warning = {}
+ title = False
+ message = False
+ partner = self.partner_id
+
+ # If partner has no warning, check its company
+ if partner.sale_warn == 'no-message' and partner.parent_id:
+ partner = partner.parent_id
+
+ if partner.sale_warn and partner.sale_warn != 'no-message':
+ # Block if partner only has warning but parent company is blocked
+ if partner.sale_warn != 'block' and partner.parent_id and partner.parent_id.sale_warn == 'block':
+ partner = partner.parent_id
+ title = ("Warning for %s") % partner.name
+ message = partner.sale_warn_msg
+ warning = {
+ 'title': title,
+ 'message': message,
+ }
+ if partner.sale_warn == 'block':
+ self.update({'partner_id': False, 'partner_invoice_id': False, 'partner_shipping_id': False, 'pricelist_id': False})
+ return {'warning': warning}
+
+ if warning:
+ return {'warning': warning}
+
+ @api.onchange('commitment_date')
+ def _onchange_commitment_date(self):
+ """ Warn if the commitment dates is sooner than the expected date """
+ if (self.commitment_date and self.expected_date and self.commitment_date < self.expected_date):
+ return {
+ 'warning': {
+ 'title': _('Requested date is too soon.'),
+ 'message': _("The delivery date is sooner than the expected date."
+ "You may be unable to honor the delivery date.")
+ }
+ }
+
+ @api.onchange('pricelist_id', 'order_line')
+ def _onchange_pricelist_id(self):
+ if self.order_line and self.pricelist_id and self._origin.pricelist_id != self.pricelist_id:
+ self.show_update_pricelist = True
+ else:
+ self.show_update_pricelist = False
+
+ def update_prices(self):
+ self.ensure_one()
+ lines_to_update = []
+ for line in self.order_line.filtered(lambda line: not line.display_type):
+ product = line.product_id.with_context(
+ partner=self.partner_id,
+ quantity=line.product_uom_qty,
+ date=self.date_order,
+ pricelist=self.pricelist_id.id,
+ uom=line.product_uom.id
+ )
+ price_unit = self.env['account.tax']._fix_tax_included_price_company(
+ line._get_display_price(product), line.product_id.taxes_id, line.tax_id, line.company_id)
+ if self.pricelist_id.discount_policy == 'without_discount' and price_unit:
+ discount = max(0, (price_unit - product.price) * 100 / price_unit)
+ else:
+ discount = 0
+ lines_to_update.append((1, line.id, {'price_unit': price_unit, 'discount': discount}))
+ self.update({'order_line': lines_to_update})
+ self.show_update_pricelist = False
+ self.message_post(body=_("Product prices have been recomputed according to pricelist <b>%s<b> ", self.pricelist_id.display_name))
+
+ @api.model
+ def create(self, vals):
+ if 'company_id' in vals:
+ self = self.with_company(vals['company_id'])
+ if vals.get('name', _('New')) == _('New'):
+ seq_date = None
+ if 'date_order' in vals:
+ seq_date = fields.Datetime.context_timestamp(self, fields.Datetime.to_datetime(vals['date_order']))
+ vals['name'] = self.env['ir.sequence'].next_by_code('sale.order', sequence_date=seq_date) or _('New')
+
+ # Makes sure partner_invoice_id', 'partner_shipping_id' and 'pricelist_id' are defined
+ if any(f not in vals for f in ['partner_invoice_id', 'partner_shipping_id', 'pricelist_id']):
+ partner = self.env['res.partner'].browse(vals.get('partner_id'))
+ addr = partner.address_get(['delivery', 'invoice'])
+ vals['partner_invoice_id'] = vals.setdefault('partner_invoice_id', addr['invoice'])
+ vals['partner_shipping_id'] = vals.setdefault('partner_shipping_id', addr['delivery'])
+ vals['pricelist_id'] = vals.setdefault('pricelist_id', partner.property_product_pricelist.id)
+ result = super(SaleOrder, self).create(vals)
+ return result
+
+ def _compute_field_value(self, field):
+ super()._compute_field_value(field)
+ if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'):
+ return
+
+ filtered_self = self.filtered(lambda so: so.user_id and so.invoice_status == 'upselling')
+ if not filtered_self:
+ return
+
+ filtered_self.activity_unlink(['sale.mail_act_sale_upsell'])
+ for order in filtered_self:
+ order.activity_schedule(
+ 'sale.mail_act_sale_upsell',
+ user_id=order.user_id.id,
+ note=_("Upsell <a href='#' data-oe-model='%s' data-oe-id='%d'>%s</a> for customer <a href='#' data-oe-model='%s' data-oe-id='%s'>%s</a>") % (
+ order._name, order.id, order.name,
+ order.partner_id._name, order.partner_id.id, order.partner_id.display_name))
+
+ def copy_data(self, default=None):
+ if default is None:
+ default = {}
+ if 'order_line' not in default:
+ default['order_line'] = [(0, 0, line.copy_data()[0]) for line in self.order_line.filtered(lambda l: not l.is_downpayment)]
+ return super(SaleOrder, self).copy_data(default)
+
+ def name_get(self):
+ if self._context.get('sale_show_partner_name'):
+ res = []
+ for order in self:
+ name = order.name
+ if order.partner_id.name:
+ name = '%s - %s' % (name, order.partner_id.name)
+ res.append((order.id, name))
+ return res
+ return super(SaleOrder, self).name_get()
+
+ @api.model
+ def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
+ if self._context.get('sale_show_partner_name'):
+ if operator == 'ilike' and not (name or '').strip():
+ domain = []
+ elif operator in ('ilike', 'like', '=', '=like', '=ilike'):
+ domain = expression.AND([
+ args or [],
+ ['|', ('name', operator, name), ('partner_id.name', operator, name)]
+ ])
+ return self._search(domain, limit=limit, access_rights_uid=name_get_uid)
+ return super(SaleOrder, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
+
+ def _prepare_invoice(self):
+ """
+ Prepare the dict of values to create the new invoice for a sales order. This method may be
+ overridden to implement custom invoice generation (making sure to call super() to establish
+ a clean extension chain).
+ """
+ self.ensure_one()
+ journal = self.env['account.move'].with_context(default_move_type='out_invoice')._get_default_journal()
+ if not journal:
+ raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id))
+
+ invoice_vals = {
+ 'ref': self.client_order_ref or '',
+ 'move_type': 'out_invoice',
+ 'narration': self.note,
+ 'currency_id': self.pricelist_id.currency_id.id,
+ 'campaign_id': self.campaign_id.id,
+ 'medium_id': self.medium_id.id,
+ 'source_id': self.source_id.id,
+ 'user_id': self.user_id.id,
+ 'invoice_user_id': self.user_id.id,
+ 'team_id': self.team_id.id,
+ 'partner_id': self.partner_invoice_id.id,
+ 'partner_shipping_id': self.partner_shipping_id.id,
+ 'fiscal_position_id': (self.fiscal_position_id or self.fiscal_position_id.get_fiscal_position(self.partner_invoice_id.id)).id,
+ 'partner_bank_id': self.company_id.partner_id.bank_ids[:1].id,
+ 'journal_id': journal.id, # company comes from the journal
+ 'invoice_origin': self.name,
+ 'invoice_payment_term_id': self.payment_term_id.id,
+ 'payment_reference': self.reference,
+ 'transaction_ids': [(6, 0, self.transaction_ids.ids)],
+ 'invoice_line_ids': [],
+ 'company_id': self.company_id.id,
+ }
+ return invoice_vals
+
+ def action_quotation_sent(self):
+ if self.filtered(lambda so: so.state != 'draft'):
+ raise UserError(_('Only draft orders can be marked as sent directly.'))
+ for order in self:
+ order.message_subscribe(partner_ids=order.partner_id.ids)
+ self.write({'state': 'sent'})
+
+ def action_view_invoice(self):
+ invoices = self.mapped('invoice_ids')
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type")
+ if len(invoices) > 1:
+ action['domain'] = [('id', 'in', invoices.ids)]
+ elif len(invoices) == 1:
+ form_view = [(self.env.ref('account.view_move_form').id, 'form')]
+ if 'views' in action:
+ action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form']
+ else:
+ action['views'] = form_view
+ action['res_id'] = invoices.id
+ else:
+ action = {'type': 'ir.actions.act_window_close'}
+
+ context = {
+ 'default_move_type': 'out_invoice',
+ }
+ if len(self) == 1:
+ context.update({
+ 'default_partner_id': self.partner_id.id,
+ 'default_partner_shipping_id': self.partner_shipping_id.id,
+ 'default_invoice_payment_term_id': self.payment_term_id.id or self.partner_id.property_payment_term_id.id or self.env['account.move'].default_get(['invoice_payment_term_id']).get('invoice_payment_term_id'),
+ 'default_invoice_origin': self.name,
+ 'default_user_id': self.user_id.id,
+ })
+ action['context'] = context
+ return action
+
+ def _get_invoice_grouping_keys(self):
+ return ['company_id', 'partner_id', 'currency_id']
+
+ @api.model
+ def _nothing_to_invoice_error(self):
+ msg = _("""There is nothing to invoice!\n
+Reason(s) of this behavior could be:
+- You should deliver your products before invoicing them: Click on the "truck" icon (top-right of your screen) and follow instructions.
+- You should modify the invoicing policy of your product: Open the product, go to the "Sales tab" and modify invoicing policy from "delivered quantities" to "ordered quantities".
+ """)
+ return UserError(msg)
+
+ def _get_invoiceable_lines(self, final=False):
+ """Return the invoiceable lines for order `self`."""
+ down_payment_line_ids = []
+ invoiceable_line_ids = []
+ pending_section = None
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+
+ for line in self.order_line:
+ if line.display_type == 'line_section':
+ # Only invoice the section if one of its lines is invoiceable
+ pending_section = line
+ continue
+ if line.display_type != 'line_note' and float_is_zero(line.qty_to_invoice, precision_digits=precision):
+ continue
+ if line.qty_to_invoice > 0 or (line.qty_to_invoice < 0 and final) or line.display_type == 'line_note':
+ if line.is_downpayment:
+ # Keep down payment lines separately, to put them together
+ # at the end of the invoice, in a specific dedicated section.
+ down_payment_line_ids.append(line.id)
+ continue
+ if pending_section:
+ invoiceable_line_ids.append(pending_section.id)
+ pending_section = None
+ invoiceable_line_ids.append(line.id)
+
+ return self.env['sale.order.line'].browse(invoiceable_line_ids + down_payment_line_ids)
+
+ def _create_invoices(self, grouped=False, final=False, date=None):
+ """
+ Create the invoice associated to the SO.
+ :param grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by
+ (partner_invoice_id, currency)
+ :param final: if True, refunds will be generated if necessary
+ :returns: list of created invoices
+ """
+ if not self.env['account.move'].check_access_rights('create', False):
+ try:
+ self.check_access_rights('write')
+ self.check_access_rule('write')
+ except AccessError:
+ return self.env['account.move']
+
+ # 1) Create invoices.
+ invoice_vals_list = []
+ invoice_item_sequence = 0 # Incremental sequencing to keep the lines order on the invoice.
+ for order in self:
+ order = order.with_company(order.company_id)
+ current_section_vals = None
+ down_payments = order.env['sale.order.line']
+
+ invoice_vals = order._prepare_invoice()
+ invoiceable_lines = order._get_invoiceable_lines(final)
+
+ if not any(not line.display_type for line in invoiceable_lines):
+ continue
+
+ invoice_line_vals = []
+ down_payment_section_added = False
+ for line in invoiceable_lines:
+ if not down_payment_section_added and line.is_downpayment:
+ # Create a dedicated section for the down payments
+ # (put at the end of the invoiceable_lines)
+ invoice_line_vals.append(
+ (0, 0, order._prepare_down_payment_section_line(
+ sequence=invoice_item_sequence,
+ )),
+ )
+ down_payment_section_added = True
+ invoice_item_sequence += 1
+ invoice_line_vals.append(
+ (0, 0, line._prepare_invoice_line(
+ sequence=invoice_item_sequence,
+ )),
+ )
+ invoice_item_sequence += 1
+
+ invoice_vals['invoice_line_ids'] += invoice_line_vals
+ invoice_vals_list.append(invoice_vals)
+
+ if not invoice_vals_list:
+ raise self._nothing_to_invoice_error()
+
+ # 2) Manage 'grouped' parameter: group by (partner_id, currency_id).
+ if not grouped:
+ new_invoice_vals_list = []
+ invoice_grouping_keys = self._get_invoice_grouping_keys()
+ invoice_vals_list = sorted(invoice_vals_list, key=lambda x: [x.get(grouping_key) for grouping_key in invoice_grouping_keys])
+ for grouping_keys, invoices in groupby(invoice_vals_list, key=lambda x: [x.get(grouping_key) for grouping_key in invoice_grouping_keys]):
+ origins = set()
+ payment_refs = set()
+ refs = set()
+ ref_invoice_vals = None
+ for invoice_vals in invoices:
+ if not ref_invoice_vals:
+ ref_invoice_vals = invoice_vals
+ else:
+ ref_invoice_vals['invoice_line_ids'] += invoice_vals['invoice_line_ids']
+ origins.add(invoice_vals['invoice_origin'])
+ payment_refs.add(invoice_vals['payment_reference'])
+ refs.add(invoice_vals['ref'])
+ ref_invoice_vals.update({
+ 'ref': ', '.join(refs)[:2000],
+ 'invoice_origin': ', '.join(origins),
+ 'payment_reference': len(payment_refs) == 1 and payment_refs.pop() or False,
+ })
+ new_invoice_vals_list.append(ref_invoice_vals)
+ invoice_vals_list = new_invoice_vals_list
+
+ # 3) Create invoices.
+
+ # As part of the invoice creation, we make sure the sequence of multiple SO do not interfere
+ # in a single invoice. Example:
+ # SO 1:
+ # - Section A (sequence: 10)
+ # - Product A (sequence: 11)
+ # SO 2:
+ # - Section B (sequence: 10)
+ # - Product B (sequence: 11)
+ #
+ # If SO 1 & 2 are grouped in the same invoice, the result will be:
+ # - Section A (sequence: 10)
+ # - Section B (sequence: 10)
+ # - Product A (sequence: 11)
+ # - Product B (sequence: 11)
+ #
+ # Resequencing should be safe, however we resequence only if there are less invoices than
+ # orders, meaning a grouping might have been done. This could also mean that only a part
+ # of the selected SO are invoiceable, but resequencing in this case shouldn't be an issue.
+ if len(invoice_vals_list) < len(self):
+ SaleOrderLine = self.env['sale.order.line']
+ for invoice in invoice_vals_list:
+ sequence = 1
+ for line in invoice['invoice_line_ids']:
+ line[2]['sequence'] = SaleOrderLine._get_invoice_line_sequence(new=sequence, old=line[2]['sequence'])
+ sequence += 1
+
+ # Manage the creation of invoices in sudo because a salesperson must be able to generate an invoice from a
+ # sale order without "billing" access rights. However, he should not be able to create an invoice from scratch.
+ moves = self.env['account.move'].sudo().with_context(default_move_type='out_invoice').create(invoice_vals_list)
+
+ # 4) Some moves might actually be refunds: convert them if the total amount is negative
+ # We do this after the moves have been created since we need taxes, etc. to know if the total
+ # is actually negative or not
+ if final:
+ moves.sudo().filtered(lambda m: m.amount_total < 0).action_switch_invoice_into_refund_credit_note()
+ for move in moves:
+ move.message_post_with_view('mail.message_origin_link',
+ values={'self': move, 'origin': move.line_ids.mapped('sale_line_ids.order_id')},
+ subtype_id=self.env.ref('mail.mt_note').id
+ )
+ return moves
+
+ def action_draft(self):
+ orders = self.filtered(lambda s: s.state in ['cancel', 'sent'])
+ return orders.write({
+ 'state': 'draft',
+ 'signature': False,
+ 'signed_by': False,
+ 'signed_on': False,
+ })
+
+ def action_cancel(self):
+ cancel_warning = self._show_cancel_wizard()
+ if cancel_warning:
+ return {
+ 'name': _('Cancel Sales Order'),
+ 'view_mode': 'form',
+ 'res_model': 'sale.order.cancel',
+ 'view_id': self.env.ref('sale.sale_order_cancel_view_form').id,
+ 'type': 'ir.actions.act_window',
+ 'context': {'default_order_id': self.id},
+ 'target': 'new'
+ }
+ inv = self.invoice_ids.filtered(lambda inv: inv.state == 'draft')
+ inv.button_cancel()
+ return self.write({'state': 'cancel'})
+
+ def _show_cancel_wizard(self):
+ for order in self:
+ if order.invoice_ids.filtered(lambda inv: inv.state == 'draft') and not order._context.get('disable_cancel_warning'):
+ return True
+ return False
+
+ def _find_mail_template(self, force_confirmation_template=False):
+ template_id = False
+
+ if force_confirmation_template or (self.state == 'sale' and not self.env.context.get('proforma', False)):
+ template_id = int(self.env['ir.config_parameter'].sudo().get_param('sale.default_confirmation_template'))
+ template_id = self.env['mail.template'].search([('id', '=', template_id)]).id
+ if not template_id:
+ template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.mail_template_sale_confirmation', raise_if_not_found=False)
+ if not template_id:
+ template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.email_template_edi_sale', raise_if_not_found=False)
+
+ return template_id
+
+ def action_quotation_send(self):
+ ''' Opens a wizard to compose an email, with relevant mail template loaded by default '''
+ self.ensure_one()
+ template_id = self._find_mail_template()
+ lang = self.env.context.get('lang')
+ template = self.env['mail.template'].browse(template_id)
+ if template.lang:
+ lang = template._render_lang(self.ids)[self.id]
+ ctx = {
+ 'default_model': 'sale.order',
+ 'default_res_id': self.ids[0],
+ 'default_use_template': bool(template_id),
+ 'default_template_id': template_id,
+ 'default_composition_mode': 'comment',
+ 'mark_so_as_sent': True,
+ 'custom_layout': "mail.mail_notification_paynow",
+ 'proforma': self.env.context.get('proforma', False),
+ 'force_email': True,
+ 'model_description': self.with_context(lang=lang).type_name,
+ }
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.compose.message',
+ 'views': [(False, 'form')],
+ 'view_id': False,
+ 'target': 'new',
+ 'context': ctx,
+ }
+
+ @api.returns('mail.message', lambda value: value.id)
+ def message_post(self, **kwargs):
+ if self.env.context.get('mark_so_as_sent'):
+ self.filtered(lambda o: o.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'})
+ return super(SaleOrder, self.with_context(mail_post_autofollow=True)).message_post(**kwargs)
+
+ def _sms_get_number_fields(self):
+ """ No phone or mobile field is available on sale model. Instead SMS will
+ fallback on partner-based computation using ``_sms_get_partner_fields``. """
+ return []
+
+ def _sms_get_partner_fields(self):
+ return ['partner_id']
+
+ def _send_order_confirmation_mail(self):
+ if self.env.su:
+ # sending mail in sudo was meant for it being sent from superuser
+ self = self.with_user(SUPERUSER_ID)
+ template_id = self._find_mail_template(force_confirmation_template=True)
+ if template_id:
+ for order in self:
+ order.with_context(force_send=True).message_post_with_template(template_id, composition_mode='comment', email_layout_xmlid="mail.mail_notification_paynow")
+
+ def action_done(self):
+ for order in self:
+ tx = order.sudo().transaction_ids.get_last_transaction()
+ if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
+ tx._set_transaction_done()
+ tx.write({'is_processed': True})
+ return self.write({'state': 'done'})
+
+ def action_unlock(self):
+ self.write({'state': 'sale'})
+
+ def _action_confirm(self):
+ """ Implementation of additionnal mecanism of Sales Order confirmation.
+ This method should be extended when the confirmation should generated
+ other documents. In this method, the SO are in 'sale' state (not yet 'done').
+ """
+ # create an analytic account if at least an expense product
+ for order in self:
+ if any(expense_policy not in [False, 'no'] for expense_policy in order.order_line.mapped('product_id.expense_policy')):
+ if not order.analytic_account_id:
+ order._create_analytic_account()
+
+ return True
+
+ def _prepare_confirmation_values(self):
+ return {
+ 'state': 'sale',
+ 'date_order': fields.Datetime.now()
+ }
+
+ def action_confirm(self):
+ if self._get_forbidden_state_confirm() & set(self.mapped('state')):
+ raise UserError(_(
+ 'It is not allowed to confirm an order in the following states: %s'
+ ) % (', '.join(self._get_forbidden_state_confirm())))
+
+ for order in self.filtered(lambda order: order.partner_id not in order.message_partner_ids):
+ order.message_subscribe([order.partner_id.id])
+ self.write(self._prepare_confirmation_values())
+
+ # Context key 'default_name' is sometimes propagated up to here.
+ # We don't need it and it creates issues in the creation of linked records.
+ context = self._context.copy()
+ context.pop('default_name', None)
+
+ self.with_context(context)._action_confirm()
+ if self.env.user.has_group('sale.group_auto_done_setting'):
+ self.action_done()
+ return True
+
+ def _get_forbidden_state_confirm(self):
+ return {'done', 'cancel'}
+
+ def _prepare_analytic_account_data(self, prefix=None):
+ """
+ Prepare method for analytic account data
+
+ :param prefix: The prefix of the to-be-created analytic account name
+ :type prefix: string
+ :return: dictionary of value for new analytic account creation
+ """
+ name = self.name
+ if prefix:
+ name = prefix + ": " + self.name
+ return {
+ 'name': name,
+ 'code': self.client_order_ref,
+ 'company_id': self.company_id.id,
+ 'partner_id': self.partner_id.id
+ }
+
+ def _create_analytic_account(self, prefix=None):
+ for order in self:
+ analytic = self.env['account.analytic.account'].create(order._prepare_analytic_account_data(prefix))
+ order.analytic_account_id = analytic
+
+ def _amount_by_group(self):
+ for order in self:
+ currency = order.currency_id or order.company_id.currency_id
+ fmt = partial(formatLang, self.with_context(lang=order.partner_id.lang).env, currency_obj=currency)
+ res = {}
+ for line in order.order_line:
+ price_reduce = line.price_unit * (1.0 - line.discount / 100.0)
+ taxes = line.tax_id.compute_all(price_reduce, quantity=line.product_uom_qty, product=line.product_id, partner=order.partner_shipping_id)['taxes']
+ for tax in line.tax_id:
+ group = tax.tax_group_id
+ res.setdefault(group, {'amount': 0.0, 'base': 0.0})
+ for t in taxes:
+ if t['id'] == tax.id or t['id'] in tax.children_tax_ids.ids:
+ res[group]['amount'] += t['amount']
+ res[group]['base'] += t['base']
+ res = sorted(res.items(), key=lambda l: l[0].sequence)
+ order.amount_by_group = [(
+ l[0].name, l[1]['amount'], l[1]['base'],
+ fmt(l[1]['amount']), fmt(l[1]['base']),
+ len(res),
+ ) for l in res]
+
+ def has_to_be_signed(self, include_draft=False):
+ return (self.state == 'sent' or (self.state == 'draft' and include_draft)) and not self.is_expired and self.require_signature and not self.signature
+
+ def has_to_be_paid(self, include_draft=False):
+ transaction = self.get_portal_last_transaction()
+ return (self.state == 'sent' or (self.state == 'draft' and include_draft)) and not self.is_expired and self.require_payment and transaction.state != 'done' and self.amount_total
+
+ def _notify_get_groups(self, msg_vals=None):
+ """ Give access button to users and portal customer as portal is integrated
+ in sale. Customer and portal group have probably no right to see
+ the document so they don't have the access button. """
+ groups = super(SaleOrder, self)._notify_get_groups(msg_vals=msg_vals)
+
+ self.ensure_one()
+ if self.state not in ('draft', 'cancel'):
+ for group_name, group_method, group_data in groups:
+ if group_name not in ('customer', 'portal'):
+ group_data['has_button_access'] = True
+
+ return groups
+
+ def _create_payment_transaction(self, vals):
+ '''Similar to self.env['payment.transaction'].create(vals) but the values are filled with the
+ current sales orders fields (e.g. the partner or the currency).
+ :param vals: The values to create a new payment.transaction.
+ :return: The newly created payment.transaction record.
+ '''
+ # Ensure the currencies are the same.
+ currency = self[0].pricelist_id.currency_id
+ if any(so.pricelist_id.currency_id != currency for so in self):
+ raise ValidationError(_('A transaction can\'t be linked to sales orders having different currencies.'))
+
+ # Ensure the partner are the same.
+ partner = self[0].partner_id
+ if any(so.partner_id != partner for so in self):
+ raise ValidationError(_('A transaction can\'t be linked to sales orders having different partners.'))
+
+ # Try to retrieve the acquirer. However, fallback to the token's acquirer.
+ acquirer_id = vals.get('acquirer_id')
+ acquirer = False
+ payment_token_id = vals.get('payment_token_id')
+
+ if payment_token_id:
+ payment_token = self.env['payment.token'].sudo().browse(payment_token_id)
+
+ # Check payment_token/acquirer matching or take the acquirer from token
+ if acquirer_id:
+ acquirer = self.env['payment.acquirer'].browse(acquirer_id)
+ if payment_token and payment_token.acquirer_id != acquirer:
+ raise ValidationError(_('Invalid token found! Token acquirer %s != %s') % (
+ payment_token.acquirer_id.name, acquirer.name))
+ if payment_token and payment_token.partner_id != partner:
+ raise ValidationError(_('Invalid token found! Token partner %s != %s') % (
+ payment_token.partner.name, partner.name))
+ else:
+ acquirer = payment_token.acquirer_id
+
+ # Check an acquirer is there.
+ if not acquirer_id and not acquirer:
+ raise ValidationError(_('A payment acquirer is required to create a transaction.'))
+
+ if not acquirer:
+ acquirer = self.env['payment.acquirer'].browse(acquirer_id)
+
+ # Check a journal is set on acquirer.
+ if not acquirer.journal_id:
+ raise ValidationError(_('A journal must be specified for the acquirer %s.', acquirer.name))
+
+ if not acquirer_id and acquirer:
+ vals['acquirer_id'] = acquirer.id
+
+ vals.update({
+ 'amount': sum(self.mapped('amount_total')),
+ 'currency_id': currency.id,
+ 'partner_id': partner.id,
+ 'sale_order_ids': [(6, 0, self.ids)],
+ 'type': self[0]._get_payment_type(vals.get('type')=='form_save'),
+ })
+
+ transaction = self.env['payment.transaction'].create(vals)
+
+ # Process directly if payment_token
+ if transaction.payment_token_id:
+ transaction.s2s_do_transaction()
+
+ return transaction
+
+ def preview_sale_order(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_url',
+ 'target': 'self',
+ 'url': self.get_portal_url(),
+ }
+
+ def _force_lines_to_invoice_policy_order(self):
+ for line in self.order_line:
+ if self.state in ['sale', 'done']:
+ line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced
+ else:
+ line.qty_to_invoice = 0
+
+ def payment_action_capture(self):
+ self.authorized_transaction_ids.s2s_capture_transaction()
+
+ def payment_action_void(self):
+ self.authorized_transaction_ids.s2s_void_transaction()
+
+ def get_portal_last_transaction(self):
+ self.ensure_one()
+ return self.transaction_ids.get_last_transaction()
+
+ @api.model
+ def _get_customer_lead(self, product_tmpl_id):
+ return False
+
+ def _get_report_base_filename(self):
+ self.ensure_one()
+ return '%s %s' % (self.type_name, self.name)
+
+ def _get_payment_type(self, tokenize=False):
+ self.ensure_one()
+ return 'form_save' if tokenize else 'form'
+
+ def _get_portal_return_action(self):
+ """ Return the action used to display orders when returning from customer portal. """
+ self.ensure_one()
+ return self.env.ref('sale.action_quotations_with_onboarding')
+
+ @api.model
+ def _prepare_down_payment_section_line(self, **optional_values):
+ """
+ Prepare the dict of values to create a new down payment section for a sales order line.
+
+ :param optional_values: any parameter that should be added to the returned down payment section
+ """
+ down_payments_section_line = {
+ 'display_type': 'line_section',
+ 'name': _('Down Payments'),
+ 'product_id': False,
+ 'product_uom_id': False,
+ 'quantity': 0,
+ 'discount': 0,
+ 'price_unit': 0,
+ 'account_id': False
+ }
+ if optional_values:
+ down_payments_section_line.update(optional_values)
+ return down_payments_section_line
+
+ 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 = "<b>" + _("The ordered quantity has been updated.") + "</b><ul>"
+ for line in order_lines:
+ msg += "<li> %s: <br/>" % 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"]
+ ) + "<br/>"
+ if line.product_id.type in ('consu', 'product'):
+ msg += _("Delivered Quantity: %s", line.qty_delivered) + "<br/>"
+ msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "<br/>"
+ msg += "</ul>"
+ 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)]")
+ 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.")
+
+ @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.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
+ uom_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty
+ price_reduce = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
+ price_subtotal = price_reduce * uom_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(
+ price_reduce,
+ currency=line.order_id.currency_id,
+ quantity=uom_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))
+
+ def unlink(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.'))
+ return super(SaleOrderLine, self).unlink()
+
+ 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
diff --git a/addons/sale/models/sales_team.py b/addons/sale/models/sales_team.py
new file mode 100644
index 00000000..b7382868
--- /dev/null
+++ b/addons/sale/models/sales_team.py
@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import date
+
+from odoo import api, fields, models, _
+
+
+class CrmTeam(models.Model):
+ _inherit = 'crm.team'
+
+ use_quotations = fields.Boolean(string='Quotations', help="Check this box if you send quotations to your customers rather than confirming orders straight away.")
+ invoiced = fields.Float(
+ compute='_compute_invoiced',
+ string='Invoiced This Month', readonly=True,
+ help="Invoice revenue for the current month. This is the amount the sales "
+ "channel has invoiced this month. It is used to compute the progression ratio "
+ "of the current and target revenue on the kanban view.")
+ invoiced_target = fields.Float(
+ string='Invoicing Target',
+ help="Revenue target for the current month (untaxed total of confirmed invoices).")
+ quotations_count = fields.Integer(
+ compute='_compute_quotations_to_invoice',
+ string='Number of quotations to invoice', readonly=True)
+ quotations_amount = fields.Float(
+ compute='_compute_quotations_to_invoice',
+ string='Amount of quotations to invoice', readonly=True)
+ sales_to_invoice_count = fields.Integer(
+ compute='_compute_sales_to_invoice',
+ string='Number of sales to invoice', readonly=True)
+
+
+ def _compute_quotations_to_invoice(self):
+ query = self.env['sale.order']._where_calc([
+ ('team_id', 'in', self.ids),
+ ('state', 'in', ['draft', 'sent']),
+ ])
+ self.env['sale.order']._apply_ir_rules(query, 'read')
+ _, where_clause, where_clause_args = query.get_sql()
+ select_query = """
+ SELECT team_id, count(*), sum(amount_total /
+ CASE COALESCE(currency_rate, 0)
+ WHEN 0 THEN 1.0
+ ELSE currency_rate
+ END
+ ) as amount_total
+ FROM sale_order
+ WHERE %s
+ GROUP BY team_id
+ """ % where_clause
+ self.env.cr.execute(select_query, where_clause_args)
+ quotation_data = self.env.cr.dictfetchall()
+ teams = self.browse()
+ for datum in quotation_data:
+ team = self.browse(datum['team_id'])
+ team.quotations_amount = datum['amount_total']
+ team.quotations_count = datum['count']
+ teams |= team
+ remaining = (self - teams)
+ remaining.quotations_amount = 0
+ remaining.quotations_count = 0
+
+ def _compute_sales_to_invoice(self):
+ sale_order_data = self.env['sale.order'].read_group([
+ ('team_id', 'in', self.ids),
+ ('invoice_status','=','to invoice'),
+ ], ['team_id'], ['team_id'])
+ data_map = {datum['team_id'][0]: datum['team_id_count'] for datum in sale_order_data}
+ for team in self:
+ team.sales_to_invoice_count = data_map.get(team.id,0.0)
+
+ def _compute_invoiced(self):
+ if not self:
+ return
+
+ query = '''
+ SELECT
+ move.team_id AS team_id,
+ SUM(-line.balance) AS amount_untaxed_signed
+ FROM account_move move
+ JOIN account_move_line line ON line.move_id = move.id
+ JOIN account_account account ON account.id = line.account_id
+ WHERE move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund')
+ AND move.payment_state IN ('in_payment', 'paid', 'reversed')
+ AND move.state = 'posted'
+ AND move.team_id IN %s
+ AND move.date BETWEEN %s AND %s
+ AND line.tax_line_id IS NULL
+ AND line.display_type IS NULL
+ AND account.internal_type NOT IN ('receivable', 'payable')
+ GROUP BY move.team_id
+ '''
+ today = fields.Date.today()
+ params = [tuple(self.ids), fields.Date.to_string(today.replace(day=1)), fields.Date.to_string(today)]
+ self._cr.execute(query, params)
+
+ data_map = dict((v[0], v[1]) for v in self._cr.fetchall())
+ for team in self:
+ team.invoiced = data_map.get(team.id, 0.0)
+
+ def _graph_get_model(self):
+ if self._context.get('in_sales_app'):
+ return 'sale.report'
+ return super(CrmTeam,self)._graph_get_model()
+
+ def _graph_date_column(self):
+ if self._context.get('in_sales_app'):
+ return 'date'
+ return super(CrmTeam,self)._graph_date_column()
+
+ def _graph_y_query(self):
+ if self._context.get('in_sales_app'):
+ return 'SUM(price_subtotal)'
+ return super(CrmTeam,self)._graph_y_query()
+
+ def _extra_sql_conditions(self):
+ if self._context.get('in_sales_app'):
+ return "AND state in ('sale', 'done', 'pos_done')"
+ return super(CrmTeam,self)._extra_sql_conditions()
+
+ def _graph_title_and_key(self):
+ if self._context.get('in_sales_app'):
+ return ['', _('Sales: Untaxed Total')] # no more title
+ return super(CrmTeam, self)._graph_title_and_key()
+
+ def _compute_dashboard_button_name(self):
+ super(CrmTeam,self)._compute_dashboard_button_name()
+ if self._context.get('in_sales_app'):
+ self.update({'dashboard_button_name': _("Sales Analysis")})
+
+ def action_primary_channel_button(self):
+ if self._context.get('in_sales_app'):
+ return self.env["ir.actions.actions"]._for_xml_id("sale.action_order_report_so_salesteam")
+ return super(CrmTeam, self).action_primary_channel_button()
+
+ def update_invoiced_target(self, value):
+ return self.write({'invoiced_target': round(float(value or 0))})
diff --git a/addons/sale/models/utm.py b/addons/sale/models/utm.py
new file mode 100644
index 00000000..cc31dfdf
--- /dev/null
+++ b/addons/sale/models/utm.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, api, SUPERUSER_ID
+
+class UtmCampaign(models.Model):
+ _inherit = 'utm.campaign'
+ _description = 'UTM Campaign'
+
+ quotation_count = fields.Integer('Quotation Count', groups='sales_team.group_sale_salesman', compute="_compute_quotation_count")
+ invoiced_amount = fields.Integer(default=0, compute="_compute_sale_invoiced_amount", string="Revenues generated by the campaign")
+ company_id = fields.Many2one('res.company', string='Company', readonly=True, states={'draft': [('readonly', False)], 'refused': [('readonly', False)]}, default=lambda self: self.env.company)
+ currency_id = fields.Many2one('res.currency', related='company_id.currency_id', string='Currency')
+
+ def _compute_quotation_count(self):
+ quotation_data = self.env['sale.order'].read_group([
+ ('campaign_id', 'in', self.ids)],
+ ['campaign_id'], ['campaign_id'])
+ data_map = {datum['campaign_id'][0]: datum['campaign_id_count'] for datum in quotation_data}
+ for campaign in self:
+ campaign.quotation_count = data_map.get(campaign.id, 0)
+
+ def _compute_sale_invoiced_amount(self):
+ self.env['account.move.line'].flush(['balance', 'move_id', 'account_id', 'exclude_from_invoice_tab'])
+ self.env['account.move'].flush(['state', 'campaign_id', 'move_type'])
+ query = """SELECT move.campaign_id, -SUM(line.balance) as price_subtotal
+ FROM account_move_line line
+ INNER JOIN account_move move ON line.move_id = move.id
+ WHERE move.state not in ('draft', 'cancel')
+ AND move.campaign_id IN %s
+ AND move.move_type IN ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')
+ AND line.account_id IS NOT NULL
+ AND NOT line.exclude_from_invoice_tab
+ GROUP BY move.campaign_id
+ """
+
+ self._cr.execute(query, [tuple(self.ids)])
+ query_res = self._cr.dictfetchall()
+
+ campaigns = self.browse()
+ for datum in query_res:
+ campaign = self.browse(datum['campaign_id'])
+ campaign.invoiced_amount = datum['price_subtotal']
+ campaigns |= campaign
+ for campaign in (self - campaigns):
+ campaign.invoiced_amount = 0
+
+ def action_redirect_to_quotations(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("sale.action_quotations_with_onboarding")
+ action['domain'] = [('campaign_id', '=', self.id)]
+ action['context'] = {
+ 'create': False,
+ 'edit': False,
+ 'default_campaign_id': self.id
+ }
+ return action
+
+ def action_redirect_to_invoiced(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
+ invoices = self.env['account.move'].search([('campaign_id', '=', self.id)])
+ action['context'] = {
+ 'create': False,
+ 'edit': False,
+ 'view_no_maturity': True
+ }
+ action['domain'] = [
+ ('id', 'in', invoices.ids),
+ ('move_type', 'in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt')),
+ ('state', 'not in', ['draft', 'cancel'])
+ ]
+ return action