diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/sale/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/sale/models')
| -rw-r--r-- | addons/sale/models/__init__.py | 16 | ||||
| -rw-r--r-- | addons/sale/models/account_invoice.py | 105 | ||||
| -rw-r--r-- | addons/sale/models/account_move.py | 234 | ||||
| -rw-r--r-- | addons/sale/models/analytic.py | 16 | ||||
| -rw-r--r-- | addons/sale/models/mail_compose_message.py | 13 | ||||
| -rw-r--r-- | addons/sale/models/payment.py | 179 | ||||
| -rw-r--r-- | addons/sale/models/product_product.py | 73 | ||||
| -rw-r--r-- | addons/sale/models/product_template.py | 294 | ||||
| -rw-r--r-- | addons/sale/models/res_company.py | 122 | ||||
| -rw-r--r-- | addons/sale/models/res_config_settings.py | 82 | ||||
| -rw-r--r-- | addons/sale/models/res_partner.py | 45 | ||||
| -rw-r--r-- | addons/sale/models/sale.py | 1911 | ||||
| -rw-r--r-- | addons/sale/models/sales_team.py | 137 | ||||
| -rw-r--r-- | addons/sale/models/utm.py | 71 |
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 |
