# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import timedelta from itertools import groupby from markupsafe import Markup from odoo import api, fields, models, SUPERUSER_ID, _ from odoo.exceptions import AccessError, UserError, ValidationError from odoo.fields import Command from odoo.osv import expression from odoo.tools import float_is_zero, format_amount, format_date, html_keep_url, is_html_empty from odoo.tools.sql import create_index from odoo.addons.payment import utils as payment_utils READONLY_FIELD_STATES = { state: [('readonly', True)] for state in {'sale', 'done', 'cancel'} } LOCKED_FIELD_STATES = { state: [('readonly', True)] for state in {'done', 'cancel'} } INVOICE_STATUS = [ ('upselling', 'Upselling Opportunity'), ('invoiced', 'Fully Invoiced'), ('to invoice', 'To Invoice'), ('no', 'Nothing to Invoice') ] 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 _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."), ] @property def _rec_names_search(self): if self._context.get('sale_show_partner_name'): return ['name', 'partner_id.name'] return ['name'] #=== FIELDS ===# name = fields.Char( string="Order Reference", required=True, copy=False, readonly=True, index='trigram', states={'draft': [('readonly', False)]}, default=lambda self: _('New')) company_id = fields.Many2one( comodel_name='res.company', required=True, index=True, default=lambda self: self.env.company) partner_id = fields.Many2one( comodel_name='res.partner', string="Customer", required=True, readonly=False, change_default=True, index=True, tracking=1, states=READONLY_FIELD_STATES, domain="[('type', '!=', 'private'), ('company_id', 'in', (False, company_id))]") state = fields.Selection( 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') client_order_ref = fields.Char(string="Customer Reference", copy=False) create_date = fields.Datetime( # Override of default create_date field from ORM string="Creation Date", index=True, readonly=True) commitment_date = fields.Datetime( string="Delivery Date", copy=False, states=LOCKED_FIELD_STATES, 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.") date_order = fields.Datetime( string="Order Date", required=True, readonly=False, copy=False, states=READONLY_FIELD_STATES, help="Creation date of draft/sent orders,\nConfirmation date of confirmed orders.", default=fields.Datetime.now) origin = fields.Char( string="Source Document", help="Reference of the document that generated this sales order request") reference = fields.Char( string="Payment Ref.", help="The payment communication of this sale order.", copy=False) require_signature = fields.Boolean( string="Online Signature", compute='_compute_require_signature', store=True, readonly=False, precompute=True, states=READONLY_FIELD_STATES, help="Request a online signature and/or payment to the customer in order to confirm orders automatically.") require_payment = fields.Boolean( string="Online Payment", compute='_compute_require_payment', store=True, readonly=False, precompute=True, states=READONLY_FIELD_STATES) signature = fields.Image( string="Signature", copy=False, attachment=True, max_width=1024, max_height=1024) signed_by = fields.Char( string="Signed By", copy=False) signed_on = fields.Datetime( string="Signed On", copy=False) validity_date = fields.Date( string="Expiration", compute='_compute_validity_date', store=True, readonly=False, copy=False, precompute=True, states=READONLY_FIELD_STATES) # Partner-based computes note = fields.Html( string="Terms and conditions", compute='_compute_note', store=True, readonly=False, precompute=True) partner_invoice_id = fields.Many2one( comodel_name='res.partner', string="Invoice Address", compute='_compute_partner_invoice_id', store=True, readonly=False, required=True, precompute=True, states=LOCKED_FIELD_STATES, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") partner_shipping_id = fields.Many2one( comodel_name='res.partner', string="Delivery Address", compute='_compute_partner_shipping_id', store=True, readonly=False, required=True, precompute=True, states=LOCKED_FIELD_STATES, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",) fiscal_position_id = fields.Many2one( comodel_name='account.fiscal.position', string="Fiscal Position", compute='_compute_fiscal_position_id', store=True, readonly=False, precompute=True, 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.", domain="[('company_id', '=', company_id)]") payment_term_id = fields.Many2one( comodel_name='account.payment.term', string="Payment Terms", compute='_compute_payment_term_id', store=True, readonly=False, precompute=True, check_company=True, # Unrequired company domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") pricelist_id = fields.Many2one( comodel_name='product.pricelist', string="Pricelist", compute='_compute_pricelist_id', store=True, readonly=False, precompute=True, check_company=True, required=True, # Unrequired company states=READONLY_FIELD_STATES, tracking=1, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", 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, precompute=True, ondelete="restrict") currency_rate = fields.Float( string="Currency Rate", compute='_compute_currency_rate', digits=0, store=True, precompute=True) user_id = fields.Many2one( comodel_name='res.users', string="Salesperson", compute='_compute_user_id', store=True, readonly=False, precompute=True, index=True, tracking=2, domain=lambda self: "[('groups_id', '=', {}), ('share', '=', False), ('company_ids', '=', company_id)]".format( self.env.ref("sales_team.group_sale_salesman").id )) team_id = fields.Many2one( comodel_name='crm.team', string="Sales Team", compute='_compute_team_id', store=True, readonly=False, precompute=True, ondelete="set null", change_default=True, check_company=True, # Unrequired company tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") # Lines and line based computes order_line = fields.One2many( comodel_name='sale.order.line', inverse_name='order_id', string="Order Lines", states=LOCKED_FIELD_STATES, copy=True, auto_join=True) amount_untaxed = fields.Monetary(string="Untaxed Amount", store=True, compute='_compute_amounts', tracking=5) amount_tax = fields.Monetary(string="Taxes", store=True, compute='_compute_amounts') amount_total = fields.Monetary(string="Total", store=True, compute='_compute_amounts', tracking=4) invoice_count = fields.Integer(string="Invoice Count", compute='_get_invoiced') invoice_ids = fields.Many2many( comodel_name='account.move', string="Invoices", compute='_get_invoiced', search='_search_invoice_ids', copy=False) invoice_status = fields.Selection( selection=INVOICE_STATUS, string="Invoice Status", compute='_compute_invoice_status', store=True) # Payment fields transaction_ids = fields.Many2many( comodel_name='payment.transaction', relation='sale_order_transaction_rel', column1='sale_order_id', column2='transaction_id', string="Transactions", copy=False, readonly=True) authorized_transaction_ids = fields.Many2many( comodel_name='payment.transaction', string="Authorized Transactions", compute='_compute_authorized_transaction_ids', copy=False) # UTMs - enforcing the fact that we want to 'set null' when relation is unlinked campaign_id = fields.Many2one(ondelete='set null') medium_id = fields.Many2one(ondelete='set null') source_id = fields.Many2one(ondelete='set null') # Followup ? analytic_account_id = fields.Many2one( comodel_name='account.analytic.account', string="Analytic Account", copy=False, check_company=True, # Unrequired company states=READONLY_FIELD_STATES, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]") tag_ids = fields.Many2many( comodel_name='crm.tag', relation='sale_order_tag_rel', column1='order_id', column2='tag_id', string="Tags") # Remaining non stored computed fields (hide/make fields readonly, ...) amount_undiscounted = fields.Float( string="Amount Before Discount", compute='_compute_amount_undiscounted', digits=0) country_code = fields.Char(related='company_id.account_fiscal_country_id.code', string="Country code") expected_date = fields.Datetime( string="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.") is_expired = fields.Boolean(string="Is Expired", compute='_compute_is_expired') partner_credit_warning = fields.Text( compute='_compute_partner_credit_warning') tax_country_id = fields.Many2one( comodel_name='res.country', compute='_compute_tax_country_id', # Avoid access error on fiscal position when reading a sale order with company != user.company_ids compute_sudo=True) # used to filter available taxes depending on the fiscal country and position tax_totals = fields.Binary(compute='_compute_tax_totals', exportable=False) terms_type = fields.Selection(related='company_id.terms_type') type_name = fields.Char(string="Type Name", compute='_compute_type_name') # Remaining ux fields (not computed, not stored) show_update_fpos = fields.Boolean( string="Has Fiscal Position Changed", store=False) # True if the fiscal position was changed show_update_pricelist = fields.Boolean( string="Has Pricelist Changed", store=False) # True if the pricelist was changed def init(self): create_index(self._cr, 'sale_order_date_order_id_idx', 'sale_order', ["date_order desc", "id desc"]) #=== COMPUTE METHODS ===# @api.depends('company_id') def _compute_require_signature(self): for order in self: order.require_signature = order.company_id.portal_confirmation_sign @api.depends('company_id') def _compute_require_payment(self): for order in self: order.require_payment = order.company_id.portal_confirmation_pay @api.depends('company_id') def _compute_validity_date(self): enabled_feature = bool(self.env['ir.config_parameter'].sudo().get_param('sale.use_quotation_validity_days')) if not enabled_feature: self.validity_date = False return today = fields.Date.context_today(self) for order in self: days = order.company_id.quotation_validity_days if days > 0: order.validity_date = today + timedelta(days) else: order.validity_date = False @api.depends('partner_id') def _compute_note(self): use_invoice_terms = self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') if not use_invoice_terms: return for order in self: order = order.with_company(order.company_id) if order.terms_type == 'html' and self.env.company.invoice_terms_html: baseurl = html_keep_url(order._get_note_url() + '/terms') context = {'lang': order.partner_id.lang or self.env.user.lang} order.note = _('Terms & Conditions: %s', baseurl) del context elif not is_html_empty(self.env.company.invoice_terms): order.note = order.with_context(lang=order.partner_id.lang).env.company.invoice_terms @api.model def _get_note_url(self): return self.env.company.get_base_url() @api.depends('partner_id') def _compute_partner_invoice_id(self): for order in self: order.partner_invoice_id = order.partner_id.address_get(['invoice'])['invoice'] if order.partner_id else False @api.depends('partner_id') def _compute_partner_shipping_id(self): for order in self: order.partner_shipping_id = order.partner_id.address_get(['delivery'])['delivery'] if order.partner_id else False @api.depends('partner_shipping_id', 'partner_id', 'company_id') def _compute_fiscal_position_id(self): """ Trigger the change of fiscal position when the shipping address is modified. """ cache = {} for order in self: if not order.partner_id: order.fiscal_position_id = False continue key = (order.company_id.id, order.partner_id.id, order.partner_shipping_id.id) if key not in cache: cache[key] = self.env['account.fiscal.position'].with_company( order.company_id )._get_fiscal_position(order.partner_id, order.partner_shipping_id) order.fiscal_position_id = cache[key] @api.depends('partner_id') def _compute_payment_term_id(self): for order in self: order = order.with_company(order.company_id) order.payment_term_id = order.partner_id.property_payment_term_id @api.depends('partner_id') def _compute_pricelist_id(self): for order in self: if not order.partner_id: order.pricelist_id = False continue order = order.with_company(order.company_id) order.pricelist_id = order.partner_id.property_product_pricelist @api.depends('currency_id', 'date_order', 'company_id') def _compute_currency_rate(self): cache = {} for order in self: order_date = (order.date_order or fields.Datetime.now()).date() if not order.company_id: order.currency_rate = order.currency_id.with_context(date=order_date).rate or 1.0 continue elif not order.currency_id: order.currency_rate = 1.0 else: key = (order.company_id.id, order_date, order.currency_id.id) if key not in cache: cache[key] = self.env['res.currency']._get_conversion_rate( from_currency=order.company_id.currency_id, to_currency=order.currency_id, company=order.company_id, date=order_date, ) order.currency_rate = cache[key] @api.depends('partner_id') def _compute_user_id(self): for order in self: if order.partner_id and not (order._origin.id and order.user_id): # Recompute the salesman on partner change # * if partner is set (is required anyway, so it will be set sooner or later) # * if the order is not saved or has no salesman already order.user_id = ( order.partner_id.user_id or order.partner_id.commercial_partner_id.user_id or (self.user_has_groups('sales_team.group_sale_salesman') and self.env.user) ) @api.depends('partner_id', 'user_id') def _compute_team_id(self): cached_teams = {} for order in self: default_team_id = self.env.context.get('default_team_id', False) or order.partner_id.team_id.id or order.team_id.id user_id = order.user_id.id company_id = order.company_id.id key = (default_team_id, user_id, company_id) if key not in cached_teams: cached_teams[key] = self.env['crm.team'].with_context( default_team_id=default_team_id )._get_default_team_id( user_id=user_id, domain=[('company_id', 'in', [company_id, False])]) order.team_id = cached_teams[key] @api.depends('order_line.price_subtotal', 'order_line.price_tax', 'order_line.price_total') def _compute_amounts(self): """Compute the total amounts of the SO.""" for order in self: order = order.with_company(order.company_id) order_lines = order.order_line.filtered(lambda x: not x.display_type) if order.company_id.tax_calculation_rounding_method == 'round_globally': tax_results = order.env['account.tax']._compute_taxes([ line._convert_to_tax_base_line_dict() for line in order_lines ]) totals = tax_results['totals'] amount_untaxed = totals.get(order.currency_id, {}).get('amount_untaxed', 0.0) amount_tax = totals.get(order.currency_id, {}).get('amount_tax', 0.0) else: amount_untaxed = sum(order_lines.mapped('price_subtotal')) amount_tax = sum(order_lines.mapped('price_tax')) order.amount_untaxed = amount_untaxed order.amount_tax = amount_tax order.amount_total = order.amount_untaxed + order.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) 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), ] @api.depends('state', 'order_line.invoice_status') def _compute_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.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 * 100)/(100-line.discount) if line.discount != 100 else (line.price_unit * line.product_uom_qty) order.amount_undiscounted = total @api.depends('order_line.customer_lead', 'date_order', '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: if order.state == 'cancel': order.expected_date = False continue dates_list = order.order_line.filtered( lambda line: not line.display_type and not line._is_delivery() ).mapped(lambda line: line and line._expected_date()) if dates_list: order.expected_date = order._select_expected_date(dates_list) else: order.expected_date = False def _select_expected_date(self, expected_dates): self.ensure_one() return min(expected_dates) 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('company_id', 'fiscal_position_id') def _compute_tax_country_id(self): for record in self: if record.fiscal_position_id.foreign_vat: record.tax_country_id = record.fiscal_position_id.country_id else: record.tax_country_id = record.company_id.account_fiscal_country_id @api.depends('company_id', 'partner_id', 'amount_total') def _compute_partner_credit_warning(self): for order in self: order.with_company(order.company_id) order.partner_credit_warning = '' show_warning = order.state in ('draft', 'sent') and \ order.company_id.account_use_credit_limit if show_warning: order_sudo = order.sudo() current_credit = order_sudo.partner_id.commercial_partner_id.credit order.partner_credit_warning = self.env['account.move']._build_credit_warning_message( record=order_sudo, updated_credit=current_credit + order.amount_total / order.currency_rate, ) @api.depends_context('lang') @api.depends('order_line.tax_id', 'order_line.price_unit', 'amount_total', 'amount_untaxed', 'currency_id') def _compute_tax_totals(self): for order in self: order = order.with_company(order.company_id) order_lines = order.order_line.filtered(lambda x: not x.display_type) order.tax_totals = order.env['account.tax']._prepare_tax_totals( [x._convert_to_tax_base_line_dict() for x in order_lines], order.currency_id or order.company_id.currency_id, ) @api.depends('state') def _compute_type_name(self): for record in self: if record.state in ('draft', 'sent', 'cancel'): record.type_name = _("Quotation") else: record.type_name = _("Sales Order") # portal.mixin override def _compute_access_url(self): super()._compute_access_url() for order in self: order.access_url = f'/my/orders/{order.id}' #=== CONSTRAINT METHODS ===# @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')), )) #=== ONCHANGE METHODS ===# @api.onchange('commitment_date', 'expected_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('fiscal_position_id') def _onchange_fpos_id_show_update_fpos(self): if self.order_line and ( not self.fiscal_position_id or (self.fiscal_position_id and self._origin.fiscal_position_id != self.fiscal_position_id) ): self.show_update_fpos = True @api.onchange('partner_id') def _onchange_partner_id_warning(self): if not self.partner_id: return 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 if partner.sale_warn == 'block': self.partner_id = False return { 'warning': { 'title': _("Warning for %s", partner.name), 'message': partner.sale_warn_msg, } } @api.onchange('pricelist_id') def _onchange_pricelist_id_show_update_prices(self): if self.order_line and self.pricelist_id and self._origin.pricelist_id != self.pricelist_id: self.show_update_pricelist = True #=== CRUD METHODS ===# @api.model_create_multi def create(self, vals_list): for vals in vals_list: if 'company_id' in vals: self = self.with_company(vals['company_id']) if vals.get('name', _("New")) == _("New"): seq_date = fields.Datetime.context_timestamp( self, fields.Datetime.to_datetime(vals['date_order']) ) if 'date_order' in vals else None vals['name'] = self.env['ir.sequence'].next_by_code( 'sale.order', sequence_date=seq_date) or _("New") return super().create(vals_list) def copy_data(self, default=None): if default is None: default = {} if 'order_line' not in default: default['order_line'] = [ Command.create(line.copy_data()[0]) for line in self.order_line.filtered(lambda l: not l.is_downpayment) ] return super().copy_data(default) @api.ondelete(at_uninstall=False) def _unlink_except_draft_or_cancel(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.")) #=== ACTION METHODS ===# 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_quotation_send(self): """ Opens a wizard to compose an email, with relevant mail template loaded by default """ self.ensure_one() self.order_line._validate_analytic_distribution() lang = self.env.context.get('lang') mail_template = self._find_mail_template() if mail_template and mail_template.lang: lang = mail_template._render_lang(self.ids)[self.id] ctx = { 'default_model': 'sale.order', 'default_res_id': self.id, 'default_use_template': bool(mail_template), 'default_template_id': mail_template.id if mail_template else None, 'default_composition_mode': 'comment', 'mark_so_as_sent': True, 'default_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature', '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, } def _find_mail_template(self): """ Get the appropriate mail template for the current sales order based on its state. If the SO is confirmed, we return the mail template for the sale confirmation. Otherwise, we return the quotation email template. :return: The correct mail template based on the current status :rtype: record of `mail.template` or `None` if not found """ self.ensure_one() if self.env.context.get('proforma') or self.state not in ('sale', 'done'): return self.env.ref('sale.email_template_edi_sale', raise_if_not_found=False) else: return self._get_confirmation_template() def _get_confirmation_template(self): """ Get the mail template sent on SO confirmation (or for confirmed SO's). :return: `mail.template` record or None if default template wasn't found """ return self.env.ref('sale.mail_template_sale_confirmation', raise_if_not_found=False) def action_quotation_sent(self): """ Mark the given draft quotation(s) as sent. :raise: UserError if any given SO is not in draft state. """ 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_confirm(self): """ Confirm the given quotation(s) and set their confirmation date. If the corresponding setting is enabled, also locks the Sale Order. :return: True :rtype: bool :raise: UserError if trying to confirm locked or cancelled SO's """ 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()), )) self.order_line._validate_analytic_distribution() for order in self: order.validate_taxes_on_sales_order() if order.partner_id in order.message_partner_ids: continue 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[:1].create_uid.has_group('sale.group_auto_done_setting'): # Public user can confirm SO, so we check the group on any record creator. self.action_done() return True def _get_forbidden_state_confirm(self): return {'done', 'cancel'} def _prepare_confirmation_values(self): """ Prepare the sales order confirmation values. Note: self can contain multiple records. :return: Sales Order confirmation values :rtype: dict """ return { 'state': 'sale', 'date_order': fields.Datetime.now() } def _action_confirm(self): """ Implementation of additional mechanism 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.product_id.mapped('expense_policy')): if not order.analytic_account_id: order._create_analytic_account() def _send_order_confirmation_mail(self): if not self: return if self.env.su: # sending mail in sudo was meant for it being sent from superuser self = self.with_user(SUPERUSER_ID) for sale_order in self: mail_template = sale_order._get_confirmation_template() if not mail_template: continue sale_order.with_context(force_send=True).message_post_with_template( mail_template.id, composition_mode='comment', email_layout_xmlid='mail.mail_notification_layout_with_responsible_signature', ) def action_done(self): self.write({'state': 'done'}) def action_unlock(self): self.write({'state': 'sale'}) def action_cancel(self): """ Cancel SO after showing the cancel wizard when needed. (cfr :meth:`_show_cancel_wizard`) For post-cancel operations, please only override :meth:`_action_cancel`. note: self.ensure_one() if the wizard is shown. """ cancel_warning = self._show_cancel_wizard() if cancel_warning: self.ensure_one() template_id = self.env['ir.model.data']._xmlid_to_res_id( 'sale.mail_template_sale_cancellation', raise_if_not_found=False ) 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_use_template': bool(template_id), 'default_template_id': template_id, 'default_order_id': self.id, 'mark_so_as_canceled': True, 'default_email_layout_xmlid': "mail.mail_notification_layout_with_responsible_signature", 'model_description': self.with_context(lang=lang).type_name, } return { 'name': _('Cancel %s', self.type_name), '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': ctx, 'target': 'new' } else: return self._action_cancel() def _action_cancel(self): inv = self.invoice_ids.filtered(lambda inv: inv.state == 'draft') inv.button_cancel() return self.write({'state': 'cancel'}) def _show_cancel_wizard(self): """ Decide whether the sale.order.cancel wizard should be shown to cancel specified orders. :return: True if there is any non-draft order in the given orders :rtype: bool """ if self.env.context.get('disable_cancel_warning'): return False return any(so.state != 'draft' for so in self) def action_preview_sale_order(self): self.ensure_one() return { 'type': 'ir.actions.act_url', 'target': 'self', 'url': self.get_portal_url(), } def action_update_taxes(self): self.ensure_one() self._recompute_taxes() if self.partner_id: self.message_post(body=_( "Product taxes have been recomputed according to fiscal position %s.", self.fiscal_position_id._get_html_link() if self.fiscal_position_id else "", )) def _recompute_taxes(self): lines_to_recompute = self.order_line.filtered(lambda line: not line.display_type) lines_to_recompute._compute_tax_id() self.show_update_fpos = False def action_update_prices(self): self.ensure_one() self._recompute_prices() if self.pricelist_id: self.message_post(body=_( "Product prices have been recomputed according to pricelist %s.", self.pricelist_id._get_html_link(), )) def _recompute_prices(self): lines_to_recompute = self._get_update_prices_lines() lines_to_recompute.invalidate_recordset(['pricelist_item_id']) lines_to_recompute._compute_price_unit() # Special case: we want to overwrite the existing discount on _recompute_prices call # i.e. to make sure the discount is correctly reset # if pricelist discount_policy is different than when the price was first computed. lines_to_recompute.discount = 0.0 lines_to_recompute._compute_discount() self.show_update_pricelist = False # INVOICING # 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() return { 'ref': self.client_order_ref or '', 'move_type': 'out_invoice', 'narration': self.note, 'currency_id': self.currency_id.id, 'campaign_id': self.campaign_id.id, 'medium_id': self.medium_id.id, 'source_id': self.source_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, 'invoice_origin': self.name, 'invoice_payment_term_id': self.payment_term_id.id, 'invoice_user_id': self.user_id.id, 'payment_reference': self.reference, 'transaction_ids': [Command.set(self.transaction_ids.ids)], 'company_id': self.company_id.id, 'invoice_line_ids': [], 'user_id': self.user_id.id, } 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, }) action['context'] = context return action def _get_invoice_grouping_keys(self): return ['company_id', 'partner_id', 'currency_id'] def _nothing_to_invoice_error_message(self): return _( "There is nothing to invoice!\n\n" "Reason(s) of this behavior could be:\n" "- You should deliver your products before invoicing them.\n" "- 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\". For Services, you should modify the Service Invoicing Policy to " "'Prepaid'." ) def _get_update_prices_lines(self): """ Hook to exclude specific lines which should not be updated based on price list recomputation """ return self.order_line.filtered(lambda line: not line.display_type) 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 invoice(s) for the given Sales Order(s). :param bool grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by keys returned by :meth:`_get_invoice_grouping_keys` :param bool final: if True, refunds will be generated if necessary :param date: unused parameter :returns: created invoices :rtype: `account.move` recordset :raises: UserError if one of the orders has no invoiceable lines. """ 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).with_context(lang=order.partner_invoice_id.lang) 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( Command.create( order._prepare_down_payment_section_line(sequence=invoice_item_sequence) ), ) down_payment_section_added = True invoice_item_sequence += 1 invoice_line_vals.append( Command.create( 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 and self._context.get('raise_if_nothing_to_invoice', True): raise UserError(self._nothing_to_invoice_error_message()) # 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.sale_line_ids.order_id}, subtype_id=self.env['ir.model.data']._xmlid_to_res_id('mail.mt_note')) return moves # MAIL # @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=self.env.context.get('mail_post_autofollow', True), lang=self.partner_id.lang, )).message_post(**kwargs) def _notify_get_recipients_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()._notify_get_recipients_groups(msg_vals=msg_vals) if not self: return groups self.ensure_one() if self._context.get('proforma'): for group in [g for g in groups if g[0] in ('portal_customer', 'portal', 'follower', 'customer')]: group[2]['has_button_access'] = False return groups local_msg_vals = dict(msg_vals or {}) # portal customers have full access (existence not granted, depending on partner_id) try: customer_portal_group = next(group for group in groups if group[0] == 'portal_customer') except StopIteration: pass else: access_opt = customer_portal_group[2].setdefault('button_access', {}) is_tx_pending = self.get_portal_last_transaction().state == 'pending' if self._has_to_be_signed(include_draft=True): if self._has_to_be_paid(): access_opt['title'] = _("View Quotation") if is_tx_pending else _("Sign & Pay Quotation") else: access_opt['title'] = _("Accept & Sign Quotation") elif self._has_to_be_paid(include_draft=True) and not is_tx_pending: access_opt['title'] = _("Accept & Pay Quotation") elif self.state in ('draft', 'sent'): access_opt['title'] = _("View Quotation") # enable followers that have access through portal follower_group = next(group for group in groups if group[0] == 'follower') follower_group[2]['active'] = True follower_group[2]['has_button_access'] = True access_opt = follower_group[2].setdefault('button_access', {}) if self.state in ('draft', 'sent'): access_opt['title'] = _("View Quotation") else: access_opt['title'] = _("View Order") access_opt['url'] = self._notify_get_action_link('view', **local_msg_vals) return groups def _notify_by_email_prepare_rendering_context(self, message, msg_vals=False, model_description=False, force_email_company=False, force_email_lang=False): render_context = super()._notify_by_email_prepare_rendering_context( message, msg_vals, model_description=model_description, force_email_company=force_email_company, force_email_lang=force_email_lang ) lang_code = render_context.get('lang') subtitles = [ render_context['record'].name, ] if self.amount_total: # Do not show the price in subtitles if zero (e.g. e-commerce orders are created empty) subtitles.append( format_amount(self.env, self.amount_total, self.currency_id, lang_code=lang_code), ) if self.validity_date and self.state in ['draft', 'sent']: formatted_date = format_date(self.env, self.validity_date, lang_code=lang_code) subtitles.append(_("Expires on %(date)s", date=formatted_date)) render_context['subtitles'] = subtitles return render_context 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 _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()._track_subtype(init_values) # PAYMENT # 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): """ Capture all transactions linked to this sale order. """ payment_utils.check_rights_on_recordset(self) # In sudo mode because we need to be able to read on provider fields. self.authorized_transaction_ids.sudo().action_capture() def payment_action_void(self): """ Void all transactions linked to this sale order. """ payment_utils.check_rights_on_recordset(self) # In sudo mode because we need to be able to read on provider fields. self.authorized_transaction_ids.sudo().action_void() def get_portal_last_transaction(self): self.ensure_one() return self.transaction_ids._get_last() def _get_order_lines_to_report(self): down_payment_lines = self.order_line.filtered(lambda line: line.is_downpayment and not line.display_type and not line._get_downpayment_state() ) def show_line(line): if not line.is_downpayment: return True elif line.display_type and down_payment_lines: return True # Only show the down payment section if down payments were posted elif line in down_payment_lines: return True # Only show posted down payments else: return False return self.order_line.filtered(show_line) def _get_default_payment_link_values(self): self.ensure_one() return { 'description': self.name, 'amount': self.amount_total - sum(self.invoice_ids.filtered(lambda x: x.state != 'cancel' and x.invoice_line_ids.sale_line_ids.order_id == self).mapped('amount_total')), 'currency_id': self.currency_id.id, 'partner_id': self.partner_invoice_id.id, 'amount_max': self.amount_total, } # PORTAL # 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 _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') def _get_report_base_filename(self): self.ensure_one() return '%s %s' % (self.type_name, self.name) #=== CORE METHODS OVERRIDES ===# @api.model def get_empty_list_help(self, help_msg): self = self.with_context( empty_list_help_document_name=_("sale order"), ) return super().get_empty_list_help(help_msg) def _compute_field_value(self, field): if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'): return super()._compute_field_value(field) filtered_self = self.filtered( lambda so: so.ids and (so.user_id or so.partner_id.user_id) and so._origin.invoice_status != 'upselling') super()._compute_field_value(field) upselling_orders = filtered_self.filtered(lambda so: so.invoice_status == 'upselling') upselling_orders._create_upsell_activity() 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().name_get() #=== BUSINESS METHODS ===# def _create_upsell_activity(self): if not self: return self.activity_unlink(['sale.mail_act_sale_upsell']) for order in self: order_ref = order._get_html_link() customer_ref = order.partner_id._get_html_link() order.activity_schedule( 'sale.mail_act_sale_upsell', user_id=order.user_id.id or order.partner_id.user_id.id, note=_("Upsell %(order)s for customer %(customer)s", order=order_ref, customer=customer_ref)) def _prepare_analytic_account_data(self, prefix=None): """ Prepare SO analytic account creation values. :param str prefix: The prefix of the to-be-created analytic account name :return: `account.analytic.account` creation values :rtype: dict """ self.ensure_one() name = self.name if prefix: name = prefix + ": " + self.name plan = self.env['account.analytic.plan'].sudo().search([ '|', ('company_id', '=', self.company_id.id), ('company_id', '=', False) ], limit=1) if not plan: plan = self.env['account.analytic.plan'].sudo().create({ 'name': 'Default', 'company_id': self.company_id.id, }) return { 'name': name, 'code': self.client_order_ref, 'company_id': self.company_id.id, 'plan_id': plan.id, 'partner_id': self.partner_id.id, } def _create_analytic_account(self, prefix=None): """ Create a new analytic account for the given orders. :param str prefix: if specified, the account name will be ': '. If not, the account name will be the Sales Order reference. :return: None """ for order in self: analytic = self.env['account.analytic.account'].create(order._prepare_analytic_account_data(prefix)) order.analytic_account_id = analytic def _prepare_down_payment_section_line(self, **optional_values): """ Prepare the values to create a new down payment section. :param dict optional_values: any parameter that should be added to the returned down payment section :return: `account.move.line` creation values :rtype: dict """ self.ensure_one() context = {'lang': self.partner_id.lang} 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, **optional_values } del context return down_payments_section_line #=== HOOKS ===# def add_option_to_order_with_taxcloud(self): self.ensure_one() def validate_taxes_on_sales_order(self): # Override for correct taxcloud computation # when using coupon and delivery return True #=== TOOLING ===# def _get_lang(self): self.ensure_one() if self.partner_id.lang and not self.partner_id.is_public: return self.partner_id.lang return self.env.lang