1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4from datetime import datetime, timedelta
5from functools import partial
6from itertools import groupby
7
8from odoo import api, fields, models, SUPERUSER_ID, _
9from odoo.exceptions import AccessError, UserError, ValidationError
10from odoo.tools.misc import formatLang, get_lang
11from odoo.osv import expression
12from odoo.tools import float_is_zero, float_compare
13
14
15
16from werkzeug.urls import url_encode
17
18
19class SaleOrder(models.Model):
20    _name = "sale.order"
21    _inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'utm.mixin']
22    _description = "Sales Order"
23    _order = 'date_order desc, id desc'
24    _check_company_auto = True
25
26    def _default_validity_date(self):
27        if self.env['ir.config_parameter'].sudo().get_param('sale.use_quotation_validity_days'):
28            days = self.env.company.quotation_validity_days
29            if days > 0:
30                return fields.Date.to_string(datetime.now() + timedelta(days))
31        return False
32
33    def _get_default_require_signature(self):
34        return self.env.company.portal_confirmation_sign
35
36    def _get_default_require_payment(self):
37        return self.env.company.portal_confirmation_pay
38
39    @api.depends('order_line.price_total')
40    def _amount_all(self):
41        """
42        Compute the total amounts of the SO.
43        """
44        for order in self:
45            amount_untaxed = amount_tax = 0.0
46            for line in order.order_line:
47                amount_untaxed += line.price_subtotal
48                amount_tax += line.price_tax
49            order.update({
50                'amount_untaxed': amount_untaxed,
51                'amount_tax': amount_tax,
52                'amount_total': amount_untaxed + amount_tax,
53            })
54
55    @api.depends('order_line.invoice_lines')
56    def _get_invoiced(self):
57        # The invoice_ids are obtained thanks to the invoice lines of the SO
58        # lines, and we also search for possible refunds created directly from
59        # existing invoices. This is necessary since such a refund is not
60        # directly linked to the SO.
61        for order in self:
62            invoices = order.order_line.invoice_lines.move_id.filtered(lambda r: r.move_type in ('out_invoice', 'out_refund'))
63            order.invoice_ids = invoices
64            order.invoice_count = len(invoices)
65
66    @api.depends('state', 'order_line.invoice_status')
67    def _get_invoice_status(self):
68        """
69        Compute the invoice status of a SO. Possible statuses:
70        - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to
71          invoice. This is also the default value if the conditions of no other status is met.
72        - to invoice: if any SO line is 'to invoice', the whole SO is 'to invoice'
73        - invoiced: if all SO lines are invoiced, the SO is invoiced.
74        - upselling: if all SO lines are invoiced or upselling, the status is upselling.
75        """
76        unconfirmed_orders = self.filtered(lambda so: so.state not in ['sale', 'done'])
77        unconfirmed_orders.invoice_status = 'no'
78        confirmed_orders = self - unconfirmed_orders
79        if not confirmed_orders:
80            return
81        line_invoice_status_all = [
82            (d['order_id'][0], d['invoice_status'])
83            for d in self.env['sale.order.line'].read_group([
84                    ('order_id', 'in', confirmed_orders.ids),
85                    ('is_downpayment', '=', False),
86                    ('display_type', '=', False),
87                ],
88                ['order_id', 'invoice_status'],
89                ['order_id', 'invoice_status'], lazy=False)]
90        for order in confirmed_orders:
91            line_invoice_status = [d[1] for d in line_invoice_status_all if d[0] == order.id]
92            if order.state not in ('sale', 'done'):
93                order.invoice_status = 'no'
94            elif any(invoice_status == 'to invoice' for invoice_status in line_invoice_status):
95                order.invoice_status = 'to invoice'
96            elif line_invoice_status and all(invoice_status == 'invoiced' for invoice_status in line_invoice_status):
97                order.invoice_status = 'invoiced'
98            elif line_invoice_status and all(invoice_status in ('invoiced', 'upselling') for invoice_status in line_invoice_status):
99                order.invoice_status = 'upselling'
100            else:
101                order.invoice_status = 'no'
102
103    @api.model
104    def get_empty_list_help(self, help):
105        self = self.with_context(
106            empty_list_help_document_name=_("sale order"),
107        )
108        return super(SaleOrder, self).get_empty_list_help(help)
109
110    @api.model
111    def _default_note(self):
112        return self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.company.invoice_terms or ''
113
114    @api.model
115    def _get_default_team(self):
116        return self.env['crm.team']._get_default_team_id()
117
118    @api.onchange('fiscal_position_id')
119    def _compute_tax_id(self):
120        """
121        Trigger the recompute of the taxes if the fiscal position is changed on the SO.
122        """
123        for order in self:
124            order.order_line._compute_tax_id()
125
126    def _search_invoice_ids(self, operator, value):
127        if operator == 'in' and value:
128            self.env.cr.execute("""
129                SELECT array_agg(so.id)
130                    FROM sale_order so
131                    JOIN sale_order_line sol ON sol.order_id = so.id
132                    JOIN sale_order_line_invoice_rel soli_rel ON soli_rel.order_line_id = sol.id
133                    JOIN account_move_line aml ON aml.id = soli_rel.invoice_line_id
134                    JOIN account_move am ON am.id = aml.move_id
135                WHERE
136                    am.move_type in ('out_invoice', 'out_refund') AND
137                    am.id = ANY(%s)
138            """, (list(value),))
139            so_ids = self.env.cr.fetchone()[0] or []
140            return [('id', 'in', so_ids)]
141        elif operator == '=' and not value:
142            # special case for [('invoice_ids', '=', False)], i.e. "Invoices is not set"
143            #
144            # We cannot just search [('order_line.invoice_lines', '=', False)]
145            # because it returns orders with uninvoiced lines, which is not
146            # same "Invoices is not set" (some lines may have invoices and some
147            # doesn't)
148            #
149            # A solution is making inverted search first ("orders with invoiced
150            # lines") and then invert results ("get all other orders")
151            #
152            # Domain below returns subset of ('order_line.invoice_lines', '!=', False)
153            order_ids = self._search([
154                ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund'))
155            ])
156            return [('id', 'not in', order_ids)]
157        return ['&', ('order_line.invoice_lines.move_id.move_type', 'in', ('out_invoice', 'out_refund')), ('order_line.invoice_lines.move_id', operator, value)]
158
159    name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New'))
160    origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.")
161    client_order_ref = fields.Char(string='Customer Reference', copy=False)
162    reference = fields.Char(string='Payment Ref.', copy=False,
163        help='The payment communication of this sale order.')
164    state = fields.Selection([
165        ('draft', 'Quotation'),
166        ('sent', 'Quotation Sent'),
167        ('sale', 'Sales Order'),
168        ('done', 'Locked'),
169        ('cancel', 'Cancelled'),
170        ], string='Status', readonly=True, copy=False, index=True, tracking=3, default='draft')
171    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.")
172    validity_date = fields.Date(string='Expiration', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
173                                default=_default_validity_date)
174    is_expired = fields.Boolean(compute='_compute_is_expired', string="Is expired")
175    require_signature = fields.Boolean('Online Signature', default=_get_default_require_signature, readonly=True,
176        states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
177        help='Request a online signature to the customer in order to confirm orders automatically.')
178    require_payment = fields.Boolean('Online Payment', default=_get_default_require_payment, readonly=True,
179        states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
180        help='Request an online payment to the customer in order to confirm orders automatically.')
181    create_date = fields.Datetime(string='Creation Date', readonly=True, index=True, help="Date on which sales order is created.")
182
183    user_id = fields.Many2one(
184        'res.users', string='Salesperson', index=True, tracking=2, default=lambda self: self.env.user,
185        domain=lambda self: [('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman').id)])
186    partner_id = fields.Many2one(
187        'res.partner', string='Customer', readonly=True,
188        states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
189        required=True, change_default=True, index=True, tracking=1,
190        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
191    partner_invoice_id = fields.Many2one(
192        'res.partner', string='Invoice Address',
193        readonly=True, required=True,
194        states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]},
195        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
196    partner_shipping_id = fields.Many2one(
197        'res.partner', string='Delivery Address', readonly=True, required=True,
198        states={'draft': [('readonly', False)], 'sent': [('readonly', False)], 'sale': [('readonly', False)]},
199        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
200
201    pricelist_id = fields.Many2one(
202        'product.pricelist', string='Pricelist', check_company=True,  # Unrequired company
203        required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
204        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", tracking=1,
205        help="If you change the pricelist, only newly added lines will be affected.")
206    currency_id = fields.Many2one(related='pricelist_id.currency_id', depends=["pricelist_id"], store=True)
207    analytic_account_id = fields.Many2one(
208        'account.analytic.account', 'Analytic Account',
209        readonly=True, copy=False, check_company=True,  # Unrequired company
210        states={'draft': [('readonly', False)], 'sent': [('readonly', False)]},
211        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
212        help="The analytic account related to a sales order.")
213
214    order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True)
215
216    invoice_count = fields.Integer(string='Invoice Count', compute='_get_invoiced', readonly=True)
217    invoice_ids = fields.Many2many("account.move", string='Invoices', compute="_get_invoiced", readonly=True, copy=False, search="_search_invoice_ids")
218    invoice_status = fields.Selection([
219        ('upselling', 'Upselling Opportunity'),
220        ('invoiced', 'Fully Invoiced'),
221        ('to invoice', 'To Invoice'),
222        ('no', 'Nothing to Invoice')
223        ], string='Invoice Status', compute='_get_invoice_status', store=True, readonly=True)
224
225    note = fields.Text('Terms and conditions', default=_default_note)
226
227    amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', tracking=5)
228    amount_by_group = fields.Binary(string="Tax amount by group", compute='_amount_by_group', help="type: [(name, amount, base, formated amount, formated base)]")
229    amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all')
230    amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_amount_all', tracking=4)
231    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')
232
233    payment_term_id = fields.Many2one(
234        'account.payment.term', string='Payment Terms', check_company=True,  # Unrequired company
235        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",)
236    fiscal_position_id = fields.Many2one(
237        'account.fiscal.position', string='Fiscal Position',
238        domain="[('company_id', '=', company_id)]", check_company=True,
239        help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices."
240        "The default value comes from the customer.")
241    company_id = fields.Many2one('res.company', 'Company', required=True, index=True, default=lambda self: self.env.company)
242    team_id = fields.Many2one(
243        'crm.team', 'Sales Team',
244        change_default=True, default=_get_default_team, check_company=True,  # Unrequired company
245        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
246
247    signature = fields.Image('Signature', help='Signature received through the portal.', copy=False, attachment=True, max_width=1024, max_height=1024)
248    signed_by = fields.Char('Signed By', help='Name of the person that signed the SO.', copy=False)
249    signed_on = fields.Datetime('Signed On', help='Date of the signature.', copy=False)
250
251    commitment_date = fields.Datetime('Delivery Date', copy=False,
252                                      states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
253                                      help="This is the delivery date promised to the customer. "
254                                           "If set, the delivery order will be scheduled based on "
255                                           "this date rather than product lead times.")
256    expected_date = fields.Datetime("Expected Date", compute='_compute_expected_date', store=False,  # Note: can not be stored since depends on today()
257        help="Delivery date you can promise to the customer, computed from the minimum lead time of the order lines.")
258    amount_undiscounted = fields.Float('Amount Before Discount', compute='_compute_amount_undiscounted', digits=0)
259
260    type_name = fields.Char('Type Name', compute='_compute_type_name')
261
262    transaction_ids = fields.Many2many('payment.transaction', 'sale_order_transaction_rel', 'sale_order_id', 'transaction_id',
263                                       string='Transactions', copy=False, readonly=True)
264    authorized_transaction_ids = fields.Many2many('payment.transaction', compute='_compute_authorized_transaction_ids',
265                                                  string='Authorized Transactions', copy=False, readonly=True)
266    show_update_pricelist = fields.Boolean(string='Has Pricelist Changed',
267                                           help="Technical Field, True if the pricelist was changed;\n"
268                                                " this will then display a recomputation button")
269    tag_ids = fields.Many2many('crm.tag', 'sale_order_tag_rel', 'order_id', 'tag_id', string='Tags')
270
271    _sql_constraints = [
272        ('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."),
273    ]
274
275    @api.constrains('company_id', 'order_line')
276    def _check_order_line_company_id(self):
277        for order in self:
278            companies = order.order_line.product_id.company_id
279            if companies and companies != order.company_id:
280                bad_products = order.order_line.product_id.filtered(lambda p: p.company_id and p.company_id != order.company_id)
281                raise ValidationError(_(
282                    "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).",
283                    product_company=', '.join(companies.mapped('display_name')),
284                    quote_company=order.company_id.display_name,
285                    bad_products=', '.join(bad_products.mapped('display_name')),
286                ))
287
288    @api.depends('pricelist_id', 'date_order', 'company_id')
289    def _compute_currency_rate(self):
290        for order in self:
291            if not order.company_id:
292                order.currency_rate = order.currency_id.with_context(date=order.date_order).rate or 1.0
293                continue
294            elif order.company_id.currency_id and order.currency_id:  # the following crashes if any one is undefined
295                order.currency_rate = self.env['res.currency']._get_conversion_rate(order.company_id.currency_id, order.currency_id, order.company_id, order.date_order)
296            else:
297                order.currency_rate = 1.0
298
299    def _compute_access_url(self):
300        super(SaleOrder, self)._compute_access_url()
301        for order in self:
302            order.access_url = '/my/orders/%s' % (order.id)
303
304    def _compute_is_expired(self):
305        today = fields.Date.today()
306        for order in self:
307            order.is_expired = order.state == 'sent' and order.validity_date and order.validity_date < today
308
309    @api.depends('order_line.customer_lead', 'date_order', 'order_line.state')
310    def _compute_expected_date(self):
311        """ For service and consumable, we only take the min dates. This method is extended in sale_stock to
312            take the picking_policy of SO into account.
313        """
314        self.mapped("order_line")  # Prefetch indication
315        for order in self:
316            dates_list = []
317            for line in order.order_line.filtered(lambda x: x.state != 'cancel' and not x._is_delivery() and not x.display_type):
318                dt = line._expected_date()
319                dates_list.append(dt)
320            if dates_list:
321                order.expected_date = fields.Datetime.to_string(min(dates_list))
322            else:
323                order.expected_date = False
324
325    @api.onchange('expected_date')
326    def _onchange_commitment_date(self):
327        self.commitment_date = self.expected_date
328
329    @api.depends('transaction_ids')
330    def _compute_authorized_transaction_ids(self):
331        for trans in self:
332            trans.authorized_transaction_ids = trans.transaction_ids.filtered(lambda t: t.state == 'authorized')
333
334    def _compute_amount_undiscounted(self):
335        for order in self:
336            total = 0.0
337            for line in order.order_line:
338                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 ??
339            order.amount_undiscounted = total
340
341    @api.depends('state')
342    def _compute_type_name(self):
343        for record in self:
344            record.type_name = _('Quotation') if record.state in ('draft', 'sent', 'cancel') else _('Sales Order')
345
346    def unlink(self):
347        for order in self:
348            if order.state not in ('draft', 'cancel'):
349                raise UserError(_('You can not delete a sent quotation or a confirmed sales order. You must first cancel it.'))
350        return super(SaleOrder, self).unlink()
351
352    def validate_taxes_on_sales_order(self):
353        # Override for correct taxcloud computation
354        # when using coupon and delivery
355        return True
356
357    def _track_subtype(self, init_values):
358        self.ensure_one()
359        if 'state' in init_values and self.state == 'sale':
360            return self.env.ref('sale.mt_order_confirmed')
361        elif 'state' in init_values and self.state == 'sent':
362            return self.env.ref('sale.mt_order_sent')
363        return super(SaleOrder, self)._track_subtype(init_values)
364
365    @api.onchange('partner_shipping_id', 'partner_id', 'company_id')
366    def onchange_partner_shipping_id(self):
367        """
368        Trigger the change of fiscal position when the shipping address is modified.
369        """
370        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)
371        return {}
372
373    @api.onchange('partner_id')
374    def onchange_partner_id(self):
375        """
376        Update the following fields when the partner is changed:
377        - Pricelist
378        - Payment terms
379        - Invoice address
380        - Delivery address
381        - Sales Team
382        """
383        if not self.partner_id:
384            self.update({
385                'partner_invoice_id': False,
386                'partner_shipping_id': False,
387                'fiscal_position_id': False,
388            })
389            return
390
391        self = self.with_company(self.company_id)
392
393        addr = self.partner_id.address_get(['delivery', 'invoice'])
394        partner_user = self.partner_id.user_id or self.partner_id.commercial_partner_id.user_id
395        values = {
396            'pricelist_id': self.partner_id.property_product_pricelist and self.partner_id.property_product_pricelist.id or False,
397            'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False,
398            'partner_invoice_id': addr['invoice'],
399            'partner_shipping_id': addr['delivery'],
400        }
401        user_id = partner_user.id
402        if not self.env.context.get('not_self_saleperson'):
403            user_id = user_id or self.env.uid
404        if user_id and self.user_id.id != user_id:
405            values['user_id'] = user_id
406
407        if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.company.invoice_terms:
408            values['note'] = self.with_context(lang=self.partner_id.lang).env.company.invoice_terms
409        if not self.env.context.get('not_self_saleperson') or not self.team_id:
410            values['team_id'] = self.env['crm.team'].with_context(
411                default_team_id=self.partner_id.team_id.id
412            )._get_default_team_id(domain=['|', ('company_id', '=', self.company_id.id), ('company_id', '=', False)], user_id=user_id)
413        self.update(values)
414
415    @api.onchange('user_id')
416    def onchange_user_id(self):
417        if self.user_id:
418            self.team_id = self.env['crm.team'].with_context(
419                default_team_id=self.team_id.id
420            )._get_default_team_id(user_id=self.user_id.id)
421
422    @api.onchange('partner_id')
423    def onchange_partner_id_warning(self):
424        if not self.partner_id:
425            return
426        warning = {}
427        title = False
428        message = False
429        partner = self.partner_id
430
431        # If partner has no warning, check its company
432        if partner.sale_warn == 'no-message' and partner.parent_id:
433            partner = partner.parent_id
434
435        if partner.sale_warn and partner.sale_warn != 'no-message':
436            # Block if partner only has warning but parent company is blocked
437            if partner.sale_warn != 'block' and partner.parent_id and partner.parent_id.sale_warn == 'block':
438                partner = partner.parent_id
439            title = ("Warning for %s") % partner.name
440            message = partner.sale_warn_msg
441            warning = {
442                    'title': title,
443                    'message': message,
444            }
445            if partner.sale_warn == 'block':
446                self.update({'partner_id': False, 'partner_invoice_id': False, 'partner_shipping_id': False, 'pricelist_id': False})
447                return {'warning': warning}
448
449        if warning:
450            return {'warning': warning}
451
452    @api.onchange('commitment_date')
453    def _onchange_commitment_date(self):
454        """ Warn if the commitment dates is sooner than the expected date """
455        if (self.commitment_date and self.expected_date and self.commitment_date < self.expected_date):
456            return {
457                'warning': {
458                    'title': _('Requested date is too soon.'),
459                    'message': _("The delivery date is sooner than the expected date."
460                                 "You may be unable to honor the delivery date.")
461                }
462            }
463
464    @api.onchange('pricelist_id', 'order_line')
465    def _onchange_pricelist_id(self):
466        if self.order_line and self.pricelist_id and self._origin.pricelist_id != self.pricelist_id:
467            self.show_update_pricelist = True
468        else:
469            self.show_update_pricelist = False
470
471    def update_prices(self):
472        self.ensure_one()
473        lines_to_update = []
474        for line in self.order_line.filtered(lambda line: not line.display_type):
475            product = line.product_id.with_context(
476                partner=self.partner_id,
477                quantity=line.product_uom_qty,
478                date=self.date_order,
479                pricelist=self.pricelist_id.id,
480                uom=line.product_uom.id
481            )
482            price_unit = self.env['account.tax']._fix_tax_included_price_company(
483                line._get_display_price(product), line.product_id.taxes_id, line.tax_id, line.company_id)
484            if self.pricelist_id.discount_policy == 'without_discount' and price_unit:
485                discount = max(0, (price_unit - product.price) * 100 / price_unit)
486            else:
487                discount = 0
488            lines_to_update.append((1, line.id, {'price_unit': price_unit, 'discount': discount}))
489        self.update({'order_line': lines_to_update})
490        self.show_update_pricelist = False
491        self.message_post(body=_("Product prices have been recomputed according to pricelist <b>%s<b> ", self.pricelist_id.display_name))
492
493    @api.model
494    def create(self, vals):
495        if 'company_id' in vals:
496            self = self.with_company(vals['company_id'])
497        if vals.get('name', _('New')) == _('New'):
498            seq_date = None
499            if 'date_order' in vals:
500                seq_date = fields.Datetime.context_timestamp(self, fields.Datetime.to_datetime(vals['date_order']))
501            vals['name'] = self.env['ir.sequence'].next_by_code('sale.order', sequence_date=seq_date) or _('New')
502
503        # Makes sure partner_invoice_id', 'partner_shipping_id' and 'pricelist_id' are defined
504        if any(f not in vals for f in ['partner_invoice_id', 'partner_shipping_id', 'pricelist_id']):
505            partner = self.env['res.partner'].browse(vals.get('partner_id'))
506            addr = partner.address_get(['delivery', 'invoice'])
507            vals['partner_invoice_id'] = vals.setdefault('partner_invoice_id', addr['invoice'])
508            vals['partner_shipping_id'] = vals.setdefault('partner_shipping_id', addr['delivery'])
509            vals['pricelist_id'] = vals.setdefault('pricelist_id', partner.property_product_pricelist.id)
510        result = super(SaleOrder, self).create(vals)
511        return result
512
513    def _compute_field_value(self, field):
514        super()._compute_field_value(field)
515        if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'):
516            return
517
518        filtered_self = self.filtered(lambda so: so.user_id and so.invoice_status == 'upselling')
519        if not filtered_self:
520            return
521
522        filtered_self.activity_unlink(['sale.mail_act_sale_upsell'])
523        for order in filtered_self:
524            order.activity_schedule(
525                'sale.mail_act_sale_upsell',
526                user_id=order.user_id.id,
527                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>") % (
528                         order._name, order.id, order.name,
529                         order.partner_id._name, order.partner_id.id, order.partner_id.display_name))
530
531    def copy_data(self, default=None):
532        if default is None:
533            default = {}
534        if 'order_line' not in default:
535            default['order_line'] = [(0, 0, line.copy_data()[0]) for line in self.order_line.filtered(lambda l: not l.is_downpayment)]
536        return super(SaleOrder, self).copy_data(default)
537
538    def name_get(self):
539        if self._context.get('sale_show_partner_name'):
540            res = []
541            for order in self:
542                name = order.name
543                if order.partner_id.name:
544                    name = '%s - %s' % (name, order.partner_id.name)
545                res.append((order.id, name))
546            return res
547        return super(SaleOrder, self).name_get()
548
549    @api.model
550    def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
551        if self._context.get('sale_show_partner_name'):
552            if operator == 'ilike' and not (name or '').strip():
553                domain = []
554            elif operator in ('ilike', 'like', '=', '=like', '=ilike'):
555                domain = expression.AND([
556                    args or [],
557                    ['|', ('name', operator, name), ('partner_id.name', operator, name)]
558                ])
559                return self._search(domain, limit=limit, access_rights_uid=name_get_uid)
560        return super(SaleOrder, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
561
562    def _prepare_invoice(self):
563        """
564        Prepare the dict of values to create the new invoice for a sales order. This method may be
565        overridden to implement custom invoice generation (making sure to call super() to establish
566        a clean extension chain).
567        """
568        self.ensure_one()
569        journal = self.env['account.move'].with_context(default_move_type='out_invoice')._get_default_journal()
570        if not journal:
571            raise UserError(_('Please define an accounting sales journal for the company %s (%s).') % (self.company_id.name, self.company_id.id))
572
573        invoice_vals = {
574            'ref': self.client_order_ref or '',
575            'move_type': 'out_invoice',
576            'narration': self.note,
577            'currency_id': self.pricelist_id.currency_id.id,
578            'campaign_id': self.campaign_id.id,
579            'medium_id': self.medium_id.id,
580            'source_id': self.source_id.id,
581            'user_id': self.user_id.id,
582            'invoice_user_id': self.user_id.id,
583            'team_id': self.team_id.id,
584            'partner_id': self.partner_invoice_id.id,
585            'partner_shipping_id': self.partner_shipping_id.id,
586            'fiscal_position_id': (self.fiscal_position_id or self.fiscal_position_id.get_fiscal_position(self.partner_invoice_id.id)).id,
587            'partner_bank_id': self.company_id.partner_id.bank_ids[:1].id,
588            'journal_id': journal.id,  # company comes from the journal
589            'invoice_origin': self.name,
590            'invoice_payment_term_id': self.payment_term_id.id,
591            'payment_reference': self.reference,
592            'transaction_ids': [(6, 0, self.transaction_ids.ids)],
593            'invoice_line_ids': [],
594            'company_id': self.company_id.id,
595        }
596        return invoice_vals
597
598    def action_quotation_sent(self):
599        if self.filtered(lambda so: so.state != 'draft'):
600            raise UserError(_('Only draft orders can be marked as sent directly.'))
601        for order in self:
602            order.message_subscribe(partner_ids=order.partner_id.ids)
603        self.write({'state': 'sent'})
604
605    def action_view_invoice(self):
606        invoices = self.mapped('invoice_ids')
607        action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type")
608        if len(invoices) > 1:
609            action['domain'] = [('id', 'in', invoices.ids)]
610        elif len(invoices) == 1:
611            form_view = [(self.env.ref('account.view_move_form').id, 'form')]
612            if 'views' in action:
613                action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form']
614            else:
615                action['views'] = form_view
616            action['res_id'] = invoices.id
617        else:
618            action = {'type': 'ir.actions.act_window_close'}
619
620        context = {
621            'default_move_type': 'out_invoice',
622        }
623        if len(self) == 1:
624            context.update({
625                'default_partner_id': self.partner_id.id,
626                'default_partner_shipping_id': self.partner_shipping_id.id,
627                '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'),
628                'default_invoice_origin': self.name,
629                'default_user_id': self.user_id.id,
630            })
631        action['context'] = context
632        return action
633
634    def _get_invoice_grouping_keys(self):
635        return ['company_id', 'partner_id', 'currency_id']
636
637    @api.model
638    def _nothing_to_invoice_error(self):
639        msg = _("""There is nothing to invoice!\n
640Reason(s) of this behavior could be:
641- You should deliver your products before invoicing them: Click on the "truck" icon (top-right of your screen) and follow instructions.
642- 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".
643        """)
644        return UserError(msg)
645
646    def _get_invoiceable_lines(self, final=False):
647        """Return the invoiceable lines for order `self`."""
648        down_payment_line_ids = []
649        invoiceable_line_ids = []
650        pending_section = None
651        precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
652
653        for line in self.order_line:
654            if line.display_type == 'line_section':
655                # Only invoice the section if one of its lines is invoiceable
656                pending_section = line
657                continue
658            if line.display_type != 'line_note' and float_is_zero(line.qty_to_invoice, precision_digits=precision):
659                continue
660            if line.qty_to_invoice > 0 or (line.qty_to_invoice < 0 and final) or line.display_type == 'line_note':
661                if line.is_downpayment:
662                    # Keep down payment lines separately, to put them together
663                    # at the end of the invoice, in a specific dedicated section.
664                    down_payment_line_ids.append(line.id)
665                    continue
666                if pending_section:
667                    invoiceable_line_ids.append(pending_section.id)
668                    pending_section = None
669                invoiceable_line_ids.append(line.id)
670
671        return self.env['sale.order.line'].browse(invoiceable_line_ids + down_payment_line_ids)
672
673    def _create_invoices(self, grouped=False, final=False, date=None):
674        """
675        Create the invoice associated to the SO.
676        :param grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by
677                        (partner_invoice_id, currency)
678        :param final: if True, refunds will be generated if necessary
679        :returns: list of created invoices
680        """
681        if not self.env['account.move'].check_access_rights('create', False):
682            try:
683                self.check_access_rights('write')
684                self.check_access_rule('write')
685            except AccessError:
686                return self.env['account.move']
687
688        # 1) Create invoices.
689        invoice_vals_list = []
690        invoice_item_sequence = 0 # Incremental sequencing to keep the lines order on the invoice.
691        for order in self:
692            order = order.with_company(order.company_id)
693            current_section_vals = None
694            down_payments = order.env['sale.order.line']
695
696            invoice_vals = order._prepare_invoice()
697            invoiceable_lines = order._get_invoiceable_lines(final)
698
699            if not any(not line.display_type for line in invoiceable_lines):
700                continue
701
702            invoice_line_vals = []
703            down_payment_section_added = False
704            for line in invoiceable_lines:
705                if not down_payment_section_added and line.is_downpayment:
706                    # Create a dedicated section for the down payments
707                    # (put at the end of the invoiceable_lines)
708                    invoice_line_vals.append(
709                        (0, 0, order._prepare_down_payment_section_line(
710                            sequence=invoice_item_sequence,
711                        )),
712                    )
713                    down_payment_section_added = True
714                    invoice_item_sequence += 1
715                invoice_line_vals.append(
716                    (0, 0, line._prepare_invoice_line(
717                        sequence=invoice_item_sequence,
718                    )),
719                )
720                invoice_item_sequence += 1
721
722            invoice_vals['invoice_line_ids'] += invoice_line_vals
723            invoice_vals_list.append(invoice_vals)
724
725        if not invoice_vals_list:
726            raise self._nothing_to_invoice_error()
727
728        # 2) Manage 'grouped' parameter: group by (partner_id, currency_id).
729        if not grouped:
730            new_invoice_vals_list = []
731            invoice_grouping_keys = self._get_invoice_grouping_keys()
732            invoice_vals_list = sorted(invoice_vals_list, key=lambda x: [x.get(grouping_key) for grouping_key in invoice_grouping_keys])
733            for grouping_keys, invoices in groupby(invoice_vals_list, key=lambda x: [x.get(grouping_key) for grouping_key in invoice_grouping_keys]):
734                origins = set()
735                payment_refs = set()
736                refs = set()
737                ref_invoice_vals = None
738                for invoice_vals in invoices:
739                    if not ref_invoice_vals:
740                        ref_invoice_vals = invoice_vals
741                    else:
742                        ref_invoice_vals['invoice_line_ids'] += invoice_vals['invoice_line_ids']
743                    origins.add(invoice_vals['invoice_origin'])
744                    payment_refs.add(invoice_vals['payment_reference'])
745                    refs.add(invoice_vals['ref'])
746                ref_invoice_vals.update({
747                    'ref': ', '.join(refs)[:2000],
748                    'invoice_origin': ', '.join(origins),
749                    'payment_reference': len(payment_refs) == 1 and payment_refs.pop() or False,
750                })
751                new_invoice_vals_list.append(ref_invoice_vals)
752            invoice_vals_list = new_invoice_vals_list
753
754        # 3) Create invoices.
755
756        # As part of the invoice creation, we make sure the sequence of multiple SO do not interfere
757        # in a single invoice. Example:
758        # SO 1:
759        # - Section A (sequence: 10)
760        # - Product A (sequence: 11)
761        # SO 2:
762        # - Section B (sequence: 10)
763        # - Product B (sequence: 11)
764        #
765        # If SO 1 & 2 are grouped in the same invoice, the result will be:
766        # - Section A (sequence: 10)
767        # - Section B (sequence: 10)
768        # - Product A (sequence: 11)
769        # - Product B (sequence: 11)
770        #
771        # Resequencing should be safe, however we resequence only if there are less invoices than
772        # orders, meaning a grouping might have been done. This could also mean that only a part
773        # of the selected SO are invoiceable, but resequencing in this case shouldn't be an issue.
774        if len(invoice_vals_list) < len(self):
775            SaleOrderLine = self.env['sale.order.line']
776            for invoice in invoice_vals_list:
777                sequence = 1
778                for line in invoice['invoice_line_ids']:
779                    line[2]['sequence'] = SaleOrderLine._get_invoice_line_sequence(new=sequence, old=line[2]['sequence'])
780                    sequence += 1
781
782        # Manage the creation of invoices in sudo because a salesperson must be able to generate an invoice from a
783        # sale order without "billing" access rights. However, he should not be able to create an invoice from scratch.
784        moves = self.env['account.move'].sudo().with_context(default_move_type='out_invoice').create(invoice_vals_list)
785
786        # 4) Some moves might actually be refunds: convert them if the total amount is negative
787        # We do this after the moves have been created since we need taxes, etc. to know if the total
788        # is actually negative or not
789        if final:
790            moves.sudo().filtered(lambda m: m.amount_total < 0).action_switch_invoice_into_refund_credit_note()
791        for move in moves:
792            move.message_post_with_view('mail.message_origin_link',
793                values={'self': move, 'origin': move.line_ids.mapped('sale_line_ids.order_id')},
794                subtype_id=self.env.ref('mail.mt_note').id
795            )
796        return moves
797
798    def action_draft(self):
799        orders = self.filtered(lambda s: s.state in ['cancel', 'sent'])
800        return orders.write({
801            'state': 'draft',
802            'signature': False,
803            'signed_by': False,
804            'signed_on': False,
805        })
806
807    def action_cancel(self):
808        cancel_warning = self._show_cancel_wizard()
809        if cancel_warning:
810            return {
811                'name': _('Cancel Sales Order'),
812                'view_mode': 'form',
813                'res_model': 'sale.order.cancel',
814                'view_id': self.env.ref('sale.sale_order_cancel_view_form').id,
815                'type': 'ir.actions.act_window',
816                'context': {'default_order_id': self.id},
817                'target': 'new'
818            }
819        inv = self.invoice_ids.filtered(lambda inv: inv.state == 'draft')
820        inv.button_cancel()
821        return self.write({'state': 'cancel'})
822
823    def _show_cancel_wizard(self):
824        for order in self:
825            if order.invoice_ids.filtered(lambda inv: inv.state == 'draft') and not order._context.get('disable_cancel_warning'):
826                return True
827        return False
828
829    def _find_mail_template(self, force_confirmation_template=False):
830        template_id = False
831
832        if force_confirmation_template or (self.state == 'sale' and not self.env.context.get('proforma', False)):
833            template_id = int(self.env['ir.config_parameter'].sudo().get_param('sale.default_confirmation_template'))
834            template_id = self.env['mail.template'].search([('id', '=', template_id)]).id
835            if not template_id:
836                template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.mail_template_sale_confirmation', raise_if_not_found=False)
837        if not template_id:
838            template_id = self.env['ir.model.data'].xmlid_to_res_id('sale.email_template_edi_sale', raise_if_not_found=False)
839
840        return template_id
841
842    def action_quotation_send(self):
843        ''' Opens a wizard to compose an email, with relevant mail template loaded by default '''
844        self.ensure_one()
845        template_id = self._find_mail_template()
846        lang = self.env.context.get('lang')
847        template = self.env['mail.template'].browse(template_id)
848        if template.lang:
849            lang = template._render_lang(self.ids)[self.id]
850        ctx = {
851            'default_model': 'sale.order',
852            'default_res_id': self.ids[0],
853            'default_use_template': bool(template_id),
854            'default_template_id': template_id,
855            'default_composition_mode': 'comment',
856            'mark_so_as_sent': True,
857            'custom_layout': "mail.mail_notification_paynow",
858            'proforma': self.env.context.get('proforma', False),
859            'force_email': True,
860            'model_description': self.with_context(lang=lang).type_name,
861        }
862        return {
863            'type': 'ir.actions.act_window',
864            'view_mode': 'form',
865            'res_model': 'mail.compose.message',
866            'views': [(False, 'form')],
867            'view_id': False,
868            'target': 'new',
869            'context': ctx,
870        }
871
872    @api.returns('mail.message', lambda value: value.id)
873    def message_post(self, **kwargs):
874        if self.env.context.get('mark_so_as_sent'):
875            self.filtered(lambda o: o.state == 'draft').with_context(tracking_disable=True).write({'state': 'sent'})
876        return super(SaleOrder, self.with_context(mail_post_autofollow=True)).message_post(**kwargs)
877
878    def _sms_get_number_fields(self):
879        """ No phone or mobile field is available on sale model. Instead SMS will
880        fallback on partner-based computation using ``_sms_get_partner_fields``. """
881        return []
882
883    def _sms_get_partner_fields(self):
884        return ['partner_id']
885
886    def _send_order_confirmation_mail(self):
887        if self.env.su:
888            # sending mail in sudo was meant for it being sent from superuser
889            self = self.with_user(SUPERUSER_ID)
890        template_id = self._find_mail_template(force_confirmation_template=True)
891        if template_id:
892            for order in self:
893                order.with_context(force_send=True).message_post_with_template(template_id, composition_mode='comment', email_layout_xmlid="mail.mail_notification_paynow")
894
895    def action_done(self):
896        for order in self:
897            tx = order.sudo().transaction_ids.get_last_transaction()
898            if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
899                tx._set_transaction_done()
900                tx.write({'is_processed': True})
901        return self.write({'state': 'done'})
902
903    def action_unlock(self):
904        self.write({'state': 'sale'})
905
906    def _action_confirm(self):
907        """ Implementation of additionnal mecanism of Sales Order confirmation.
908            This method should be extended when the confirmation should generated
909            other documents. In this method, the SO are in 'sale' state (not yet 'done').
910        """
911        # create an analytic account if at least an expense product
912        for order in self:
913            if any(expense_policy not in [False, 'no'] for expense_policy in order.order_line.mapped('product_id.expense_policy')):
914                if not order.analytic_account_id:
915                    order._create_analytic_account()
916
917        return True
918
919    def _prepare_confirmation_values(self):
920        return {
921            'state': 'sale',
922            'date_order': fields.Datetime.now()
923        }
924
925    def action_confirm(self):
926        if self._get_forbidden_state_confirm() & set(self.mapped('state')):
927            raise UserError(_(
928                'It is not allowed to confirm an order in the following states: %s'
929            ) % (', '.join(self._get_forbidden_state_confirm())))
930
931        for order in self.filtered(lambda order: order.partner_id not in order.message_partner_ids):
932            order.message_subscribe([order.partner_id.id])
933        self.write(self._prepare_confirmation_values())
934
935        # Context key 'default_name' is sometimes propagated up to here.
936        # We don't need it and it creates issues in the creation of linked records.
937        context = self._context.copy()
938        context.pop('default_name', None)
939
940        self.with_context(context)._action_confirm()
941        if self.env.user.has_group('sale.group_auto_done_setting'):
942            self.action_done()
943        return True
944
945    def _get_forbidden_state_confirm(self):
946        return {'done', 'cancel'}
947
948    def _prepare_analytic_account_data(self, prefix=None):
949        """
950        Prepare method for analytic account data
951
952        :param prefix: The prefix of the to-be-created analytic account name
953        :type prefix: string
954        :return: dictionary of value for new analytic account creation
955        """
956        name = self.name
957        if prefix:
958            name = prefix + ": " + self.name
959        return {
960            'name': name,
961            'code': self.client_order_ref,
962            'company_id': self.company_id.id,
963            'partner_id': self.partner_id.id
964        }
965
966    def _create_analytic_account(self, prefix=None):
967        for order in self:
968            analytic = self.env['account.analytic.account'].create(order._prepare_analytic_account_data(prefix))
969            order.analytic_account_id = analytic
970
971    def _amount_by_group(self):
972        for order in self:
973            currency = order.currency_id or order.company_id.currency_id
974            fmt = partial(formatLang, self.with_context(lang=order.partner_id.lang).env, currency_obj=currency)
975            res = {}
976            for line in order.order_line:
977                price_reduce = line.price_unit * (1.0 - line.discount / 100.0)
978                taxes = line.tax_id.compute_all(price_reduce, quantity=line.product_uom_qty, product=line.product_id, partner=order.partner_shipping_id)['taxes']
979                for tax in line.tax_id:
980                    group = tax.tax_group_id
981                    res.setdefault(group, {'amount': 0.0, 'base': 0.0})
982                    for t in taxes:
983                        if t['id'] == tax.id or t['id'] in tax.children_tax_ids.ids:
984                            res[group]['amount'] += t['amount']
985                            res[group]['base'] += t['base']
986            res = sorted(res.items(), key=lambda l: l[0].sequence)
987            order.amount_by_group = [(
988                l[0].name, l[1]['amount'], l[1]['base'],
989                fmt(l[1]['amount']), fmt(l[1]['base']),
990                len(res),
991            ) for l in res]
992
993    def has_to_be_signed(self, include_draft=False):
994        return (self.state == 'sent' or (self.state == 'draft' and include_draft)) and not self.is_expired and self.require_signature and not self.signature
995
996    def has_to_be_paid(self, include_draft=False):
997        transaction = self.get_portal_last_transaction()
998        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
999
1000    def _notify_get_groups(self, msg_vals=None):
1001        """ Give access button to users and portal customer as portal is integrated
1002        in sale. Customer and portal group have probably no right to see
1003        the document so they don't have the access button. """
1004        groups = super(SaleOrder, self)._notify_get_groups(msg_vals=msg_vals)
1005
1006        self.ensure_one()
1007        if self.state not in ('draft', 'cancel'):
1008            for group_name, group_method, group_data in groups:
1009                if group_name not in ('customer', 'portal'):
1010                    group_data['has_button_access'] = True
1011
1012        return groups
1013
1014    def _create_payment_transaction(self, vals):
1015        '''Similar to self.env['payment.transaction'].create(vals) but the values are filled with the
1016        current sales orders fields (e.g. the partner or the currency).
1017        :param vals: The values to create a new payment.transaction.
1018        :return: The newly created payment.transaction record.
1019        '''
1020        # Ensure the currencies are the same.
1021        currency = self[0].pricelist_id.currency_id
1022        if any(so.pricelist_id.currency_id != currency for so in self):
1023            raise ValidationError(_('A transaction can\'t be linked to sales orders having different currencies.'))
1024
1025        # Ensure the partner are the same.
1026        partner = self[0].partner_id
1027        if any(so.partner_id != partner for so in self):
1028            raise ValidationError(_('A transaction can\'t be linked to sales orders having different partners.'))
1029
1030        # Try to retrieve the acquirer. However, fallback to the token's acquirer.
1031        acquirer_id = vals.get('acquirer_id')
1032        acquirer = False
1033        payment_token_id = vals.get('payment_token_id')
1034
1035        if payment_token_id:
1036            payment_token = self.env['payment.token'].sudo().browse(payment_token_id)
1037
1038            # Check payment_token/acquirer matching or take the acquirer from token
1039            if acquirer_id:
1040                acquirer = self.env['payment.acquirer'].browse(acquirer_id)
1041                if payment_token and payment_token.acquirer_id != acquirer:
1042                    raise ValidationError(_('Invalid token found! Token acquirer %s != %s') % (
1043                    payment_token.acquirer_id.name, acquirer.name))
1044                if payment_token and payment_token.partner_id != partner:
1045                    raise ValidationError(_('Invalid token found! Token partner %s != %s') % (
1046                    payment_token.partner.name, partner.name))
1047            else:
1048                acquirer = payment_token.acquirer_id
1049
1050        # Check an acquirer is there.
1051        if not acquirer_id and not acquirer:
1052            raise ValidationError(_('A payment acquirer is required to create a transaction.'))
1053
1054        if not acquirer:
1055            acquirer = self.env['payment.acquirer'].browse(acquirer_id)
1056
1057        # Check a journal is set on acquirer.
1058        if not acquirer.journal_id:
1059            raise ValidationError(_('A journal must be specified for the acquirer %s.', acquirer.name))
1060
1061        if not acquirer_id and acquirer:
1062            vals['acquirer_id'] = acquirer.id
1063
1064        vals.update({
1065            'amount': sum(self.mapped('amount_total')),
1066            'currency_id': currency.id,
1067            'partner_id': partner.id,
1068            'sale_order_ids': [(6, 0, self.ids)],
1069            'type': self[0]._get_payment_type(vals.get('type')=='form_save'),
1070        })
1071
1072        transaction = self.env['payment.transaction'].create(vals)
1073
1074        # Process directly if payment_token
1075        if transaction.payment_token_id:
1076            transaction.s2s_do_transaction()
1077
1078        return transaction
1079
1080    def preview_sale_order(self):
1081        self.ensure_one()
1082        return {
1083            'type': 'ir.actions.act_url',
1084            'target': 'self',
1085            'url': self.get_portal_url(),
1086        }
1087
1088    def _force_lines_to_invoice_policy_order(self):
1089        for line in self.order_line:
1090            if self.state in ['sale', 'done']:
1091                line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced
1092            else:
1093                line.qty_to_invoice = 0
1094
1095    def payment_action_capture(self):
1096        self.authorized_transaction_ids.s2s_capture_transaction()
1097
1098    def payment_action_void(self):
1099        self.authorized_transaction_ids.s2s_void_transaction()
1100
1101    def get_portal_last_transaction(self):
1102        self.ensure_one()
1103        return self.transaction_ids.get_last_transaction()
1104
1105    @api.model
1106    def _get_customer_lead(self, product_tmpl_id):
1107        return False
1108
1109    def _get_report_base_filename(self):
1110        self.ensure_one()
1111        return '%s %s' % (self.type_name, self.name)
1112
1113    def _get_payment_type(self, tokenize=False):
1114        self.ensure_one()
1115        return 'form_save' if tokenize else 'form'
1116
1117    def _get_portal_return_action(self):
1118        """ Return the action used to display orders when returning from customer portal. """
1119        self.ensure_one()
1120        return self.env.ref('sale.action_quotations_with_onboarding')
1121
1122    @api.model
1123    def _prepare_down_payment_section_line(self, **optional_values):
1124        """
1125        Prepare the dict of values to create a new down payment section for a sales order line.
1126
1127        :param optional_values: any parameter that should be added to the returned down payment section
1128        """
1129        down_payments_section_line = {
1130            'display_type': 'line_section',
1131            'name': _('Down Payments'),
1132            'product_id': False,
1133            'product_uom_id': False,
1134            'quantity': 0,
1135            'discount': 0,
1136            'price_unit': 0,
1137            'account_id': False
1138        }
1139        if optional_values:
1140            down_payments_section_line.update(optional_values)
1141        return down_payments_section_line
1142
1143    def add_option_to_order_with_taxcloud(self):
1144        self.ensure_one()
1145
1146
1147class SaleOrderLine(models.Model):
1148    _name = 'sale.order.line'
1149    _description = 'Sales Order Line'
1150    _order = 'order_id, sequence, id'
1151    _check_company_auto = True
1152
1153    @api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced')
1154    def _compute_invoice_status(self):
1155        """
1156        Compute the invoice status of a SO line. Possible statuses:
1157        - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to
1158          invoice. This is also hte default value if the conditions of no other status is met.
1159        - to invoice: we refer to the quantity to invoice of the line. Refer to method
1160          `_get_to_invoice_qty()` for more information on how this quantity is calculated.
1161        - upselling: this is possible only for a product invoiced on ordered quantities for which
1162          we delivered more than expected. The could arise if, for example, a project took more
1163          time than expected but we decided not to invoice the extra cost to the client. This
1164          occurs onyl in state 'sale', so that when a SO is set to done, the upselling opportunity
1165          is removed from the list.
1166        - invoiced: the quantity invoiced is larger or equal to the quantity ordered.
1167        """
1168        precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1169        for line in self:
1170            if line.state not in ('sale', 'done'):
1171                line.invoice_status = 'no'
1172            elif line.is_downpayment and line.untaxed_amount_to_invoice == 0:
1173                line.invoice_status = 'invoiced'
1174            elif not float_is_zero(line.qty_to_invoice, precision_digits=precision):
1175                line.invoice_status = 'to invoice'
1176            elif line.state == 'sale' and line.product_id.invoice_policy == 'order' and\
1177                    float_compare(line.qty_delivered, line.product_uom_qty, precision_digits=precision) == 1:
1178                line.invoice_status = 'upselling'
1179            elif float_compare(line.qty_invoiced, line.product_uom_qty, precision_digits=precision) >= 0:
1180                line.invoice_status = 'invoiced'
1181            else:
1182                line.invoice_status = 'no'
1183
1184    def _expected_date(self):
1185        self.ensure_one()
1186        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())
1187        return order_date + timedelta(days=self.customer_lead or 0.0)
1188
1189    @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id')
1190    def _compute_amount(self):
1191        """
1192        Compute the amounts of the SO line.
1193        """
1194        for line in self:
1195            price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
1196            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)
1197            line.update({
1198                'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])),
1199                'price_total': taxes['total_included'],
1200                'price_subtotal': taxes['total_excluded'],
1201            })
1202            if self.env.context.get('import_file', False) and not self.env.user.user_has_groups('account.group_account_manager'):
1203                line.tax_id.invalidate_cache(['invoice_repartition_line_ids'], [line.tax_id.id])
1204
1205    @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered')
1206    def _compute_product_updatable(self):
1207        for line in self:
1208            if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)):
1209                line.product_updatable = False
1210            else:
1211                line.product_updatable = True
1212
1213    # no trigger product_id.invoice_policy to avoid retroactively changing SO
1214    @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state')
1215    def _get_to_invoice_qty(self):
1216        """
1217        Compute the quantity to invoice. If the invoice policy is order, the quantity to invoice is
1218        calculated from the ordered quantity. Otherwise, the quantity delivered is used.
1219        """
1220        for line in self:
1221            if line.order_id.state in ['sale', 'done']:
1222                if line.product_id.invoice_policy == 'order':
1223                    line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced
1224                else:
1225                    line.qty_to_invoice = line.qty_delivered - line.qty_invoiced
1226            else:
1227                line.qty_to_invoice = 0
1228
1229    @api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity', 'untaxed_amount_to_invoice')
1230    def _get_invoice_qty(self):
1231        """
1232        Compute the quantity invoiced. If case of a refund, the quantity invoiced is decreased. Note
1233        that this is the case only if the refund is generated from the SO and that is intentional: if
1234        a refund made would automatically decrease the invoiced quantity, then there is a risk of reinvoicing
1235        it automatically, which may not be wanted at all. That's why the refund has to be created from the SO
1236        """
1237        for line in self:
1238            qty_invoiced = 0.0
1239            for invoice_line in line.invoice_lines:
1240                if invoice_line.move_id.state != 'cancel':
1241                    if invoice_line.move_id.move_type == 'out_invoice':
1242                        qty_invoiced += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom)
1243                    elif invoice_line.move_id.move_type == 'out_refund':
1244                        if not line.is_downpayment or line.untaxed_amount_to_invoice == 0 :
1245                            qty_invoiced -= invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom)
1246            line.qty_invoiced = qty_invoiced
1247
1248    @api.depends('price_unit', 'discount')
1249    def _get_price_reduce(self):
1250        for line in self:
1251            line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0)
1252
1253    @api.depends('price_total', 'product_uom_qty')
1254    def _get_price_reduce_tax(self):
1255        for line in self:
1256            line.price_reduce_taxinc = line.price_total / line.product_uom_qty if line.product_uom_qty else 0.0
1257
1258    @api.depends('price_subtotal', 'product_uom_qty')
1259    def _get_price_reduce_notax(self):
1260        for line in self:
1261            line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0
1262
1263    def _compute_tax_id(self):
1264        for line in self:
1265            line = line.with_company(line.company_id)
1266            fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id.get_fiscal_position(line.order_partner_id.id)
1267            # If company_id is set, always filter taxes by the company
1268            taxes = line.product_id.taxes_id.filtered(lambda t: t.company_id == line.env.company)
1269            line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id)
1270
1271    @api.model
1272    def _prepare_add_missing_fields(self, values):
1273        """ Deduce missing required fields from the onchange """
1274        res = {}
1275        onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_id']
1276        if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields):
1277            line = self.new(values)
1278            line.product_id_change()
1279            for field in onchange_fields:
1280                if field not in values:
1281                    res[field] = line._fields[field].convert_to_write(line[field], line)
1282        return res
1283
1284    @api.model_create_multi
1285    def create(self, vals_list):
1286        for values in vals_list:
1287            if values.get('display_type', self.default_get(['display_type'])['display_type']):
1288                values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0)
1289
1290            values.update(self._prepare_add_missing_fields(values))
1291
1292        lines = super().create(vals_list)
1293        for line in lines:
1294            if line.product_id and line.order_id.state == 'sale':
1295                msg = _("Extra line with %s ") % (line.product_id.display_name,)
1296                line.order_id.message_post(body=msg)
1297                # create an analytic account if at least an expense product
1298                if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id:
1299                    line.order_id._create_analytic_account()
1300        return lines
1301
1302    _sql_constraints = [
1303        ('accountable_required_fields',
1304            "CHECK(display_type IS NOT NULL OR (product_id IS NOT NULL AND product_uom IS NOT NULL))",
1305            "Missing required fields on accountable sale order line."),
1306        ('non_accountable_null_fields',
1307            "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))",
1308            "Forbidden values on non-accountable sale order line"),
1309    ]
1310
1311    def _update_line_quantity(self, values):
1312        orders = self.mapped('order_id')
1313        for order in orders:
1314            order_lines = self.filtered(lambda x: x.order_id == order)
1315            msg = "<b>" + _("The ordered quantity has been updated.") + "</b><ul>"
1316            for line in order_lines:
1317                msg += "<li> %s: <br/>" % line.product_id.display_name
1318                msg += _(
1319                    "Ordered Quantity: %(old_qty)s -> %(new_qty)s",
1320                    old_qty=line.product_uom_qty,
1321                    new_qty=values["product_uom_qty"]
1322                ) + "<br/>"
1323                if line.product_id.type in ('consu', 'product'):
1324                    msg += _("Delivered Quantity: %s", line.qty_delivered) + "<br/>"
1325                msg += _("Invoiced Quantity: %s", line.qty_invoiced) + "<br/>"
1326            msg += "</ul>"
1327            order.message_post(body=msg)
1328
1329    def write(self, values):
1330        if 'display_type' in values and self.filtered(lambda line: line.display_type != values.get('display_type')):
1331            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."))
1332
1333        if 'product_uom_qty' in values:
1334            precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
1335            self.filtered(
1336                lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0)._update_line_quantity(values)
1337
1338        # Prevent writing on a locked SO.
1339        protected_fields = self._get_protected_fields()
1340        if 'done' in self.mapped('order_id.state') and any(f in values.keys() for f in protected_fields):
1341            protected_fields_modified = list(set(protected_fields) & set(values.keys()))
1342            fields = self.env['ir.model.fields'].search([
1343                ('name', 'in', protected_fields_modified), ('model', '=', self._name)
1344            ])
1345            raise UserError(
1346                _('It is forbidden to modify the following fields in a locked order:\n%s')
1347                % '\n'.join(fields.mapped('field_description'))
1348            )
1349
1350        result = super(SaleOrderLine, self).write(values)
1351        return result
1352
1353    order_id = fields.Many2one('sale.order', string='Order Reference', required=True, ondelete='cascade', index=True, copy=False)
1354    name = fields.Text(string='Description', required=True)
1355    sequence = fields.Integer(string='Sequence', default=10)
1356
1357    invoice_lines = fields.Many2many('account.move.line', 'sale_order_line_invoice_rel', 'order_line_id', 'invoice_line_id', string='Invoice Lines', copy=False)
1358    invoice_status = fields.Selection([
1359        ('upselling', 'Upselling Opportunity'),
1360        ('invoiced', 'Fully Invoiced'),
1361        ('to invoice', 'To Invoice'),
1362        ('no', 'Nothing to Invoice')
1363        ], string='Invoice Status', compute='_compute_invoice_status', store=True, readonly=True, default='no')
1364    price_unit = fields.Float('Unit Price', required=True, digits='Product Price', default=0.0)
1365
1366    price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', readonly=True, store=True)
1367    price_tax = fields.Float(compute='_compute_amount', string='Total Tax', readonly=True, store=True)
1368    price_total = fields.Monetary(compute='_compute_amount', string='Total', readonly=True, store=True)
1369
1370    price_reduce = fields.Float(compute='_get_price_reduce', string='Price Reduce', digits='Product Price', readonly=True, store=True)
1371    tax_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)])
1372    price_reduce_taxinc = fields.Monetary(compute='_get_price_reduce_tax', string='Price Reduce Tax inc', readonly=True, store=True)
1373    price_reduce_taxexcl = fields.Monetary(compute='_get_price_reduce_notax', string='Price Reduce Tax excl', readonly=True, store=True)
1374
1375    discount = fields.Float(string='Discount (%)', digits='Discount', default=0.0)
1376
1377    product_id = fields.Many2one(
1378        'product.product', string='Product', domain="[('sale_ok', '=', True), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
1379        change_default=True, ondelete='restrict', check_company=True)  # Unrequired company
1380    product_template_id = fields.Many2one(
1381        'product.template', string='Product Template',
1382        related="product_id.product_tmpl_id", domain=[('sale_ok', '=', True)])
1383    product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True)
1384    product_uom_qty = fields.Float(string='Quantity', digits='Product Unit of Measure', required=True, default=1.0)
1385    product_uom = fields.Many2one('uom.uom', string='Unit of Measure', domain="[('category_id', '=', product_uom_category_id)]")
1386    product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id', readonly=True)
1387    product_uom_readonly = fields.Boolean(compute='_compute_product_uom_readonly')
1388    product_custom_attribute_value_ids = fields.One2many('product.attribute.custom.value', 'sale_order_line_id', string="Custom Values", copy=True)
1389
1390    # M2M holding the values of product.attribute with create_variant field set to 'no_variant'
1391    # It allows keeping track of the extra_price associated to those attribute values and add them to the SO line description
1392    product_no_variant_attribute_value_ids = fields.Many2many('product.template.attribute.value', string="Extra Values", ondelete='restrict')
1393
1394    qty_delivered_method = fields.Selection([
1395        ('manual', 'Manual'),
1396        ('analytic', 'Analytic From Expenses')
1397    ], string="Method to update delivered qty", compute='_compute_qty_delivered_method', compute_sudo=True, store=True, readonly=True,
1398        help="According to product configuration, the delivered quantity can be automatically computed by mechanism :\n"
1399             "  - Manual: the quantity is set manually on the line\n"
1400             "  - Analytic From expenses: the quantity is the quantity sum from posted expenses\n"
1401             "  - Timesheet: the quantity is the sum of hours recorded on tasks linked to this sale line\n"
1402             "  - Stock Moves: the quantity comes from confirmed pickings\n")
1403    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)
1404    qty_delivered_manual = fields.Float('Delivered Manually', copy=False, digits='Product Unit of Measure', default=0.0)
1405    qty_to_invoice = fields.Float(
1406        compute='_get_to_invoice_qty', string='To Invoice Quantity', store=True, readonly=True,
1407        digits='Product Unit of Measure')
1408    qty_invoiced = fields.Float(
1409        compute='_get_invoice_qty', string='Invoiced Quantity', store=True, readonly=True,
1410        compute_sudo=True,
1411        digits='Product Unit of Measure')
1412
1413    untaxed_amount_invoiced = fields.Monetary("Untaxed Invoiced Amount", compute='_compute_untaxed_amount_invoiced', compute_sudo=True, store=True)
1414    untaxed_amount_to_invoice = fields.Monetary("Untaxed Amount To Invoice", compute='_compute_untaxed_amount_to_invoice', compute_sudo=True, store=True)
1415
1416    salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', readonly=True)
1417    currency_id = fields.Many2one(related='order_id.currency_id', depends=['order_id.currency_id'], store=True, string='Currency', readonly=True)
1418    company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True, index=True)
1419    order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer', readonly=False)
1420    analytic_tag_ids = fields.Many2many(
1421        'account.analytic.tag', string='Analytic Tags',
1422        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
1423    analytic_line_ids = fields.One2many('account.analytic.line', 'so_line', string="Analytic lines")
1424    is_expense = fields.Boolean('Is expense', help="Is true if the sales order line comes from an expense or a vendor bills")
1425    is_downpayment = fields.Boolean(
1426        string="Is a down payment", help="Down payments are made when creating invoices from a sales order."
1427        " They are not copied when duplicating a sales order.")
1428
1429    state = fields.Selection(
1430        related='order_id.state', string='Order Status', readonly=True, copy=False, store=True, default='draft')
1431
1432    customer_lead = fields.Float(
1433        'Lead Time', required=True, default=0.0,
1434        help="Number of days between the order confirmation and the shipping of the products to the customer")
1435
1436    display_type = fields.Selection([
1437        ('line_section', "Section"),
1438        ('line_note', "Note")], default=False, help="Technical field for UX purpose.")
1439
1440    @api.depends('state')
1441    def _compute_product_uom_readonly(self):
1442        for line in self:
1443            line.product_uom_readonly = line.state in ['sale', 'done', 'cancel']
1444
1445    @api.depends('state', 'is_expense')
1446    def _compute_qty_delivered_method(self):
1447        """ Sale module compute delivered qty for product [('type', 'in', ['consu']), ('service_type', '=', 'manual')]
1448                - consu + expense_policy : analytic (sum of analytic unit_amount)
1449                - consu + no expense_policy : manual (set manually on SOL)
1450                - service (+ service_type='manual', the only available option) : manual
1451
1452            This is true when only sale is installed: sale_stock redifine the behavior for 'consu' type,
1453            and sale_timesheet implements the behavior of 'service' + service_type=timesheet.
1454        """
1455        for line in self:
1456            if line.is_expense:
1457                line.qty_delivered_method = 'analytic'
1458            else:  # service and consu
1459                line.qty_delivered_method = 'manual'
1460
1461    @api.depends('qty_delivered_method', 'qty_delivered_manual', 'analytic_line_ids.so_line', 'analytic_line_ids.unit_amount', 'analytic_line_ids.product_uom_id')
1462    def _compute_qty_delivered(self):
1463        """ This method compute the delivered quantity of the SO lines: it covers the case provide by sale module, aka
1464            expense/vendor bills (sum of unit_amount of AAL), and manual case.
1465            This method should be overridden to provide other way to automatically compute delivered qty. Overrides should
1466            take their concerned so lines, compute and set the `qty_delivered` field, and call super with the remaining
1467            records.
1468        """
1469        # compute for analytic lines
1470        lines_by_analytic = self.filtered(lambda sol: sol.qty_delivered_method == 'analytic')
1471        mapping = lines_by_analytic._get_delivered_quantity_by_analytic([('amount', '<=', 0.0)])
1472        for so_line in lines_by_analytic:
1473            so_line.qty_delivered = mapping.get(so_line.id or so_line._origin.id, 0.0)
1474        # compute for manual lines
1475        for line in self:
1476            if line.qty_delivered_method == 'manual':
1477                line.qty_delivered = line.qty_delivered_manual or 0.0
1478
1479    def _get_delivered_quantity_by_analytic(self, additional_domain):
1480        """ Compute and write the delivered quantity of current SO lines, based on their related
1481            analytic lines.
1482            :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...)
1483        """
1484        result = {}
1485
1486        # avoid recomputation if no SO lines concerned
1487        if not self:
1488            return result
1489
1490        # group analytic lines by product uom and so line
1491        domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain])
1492        data = self.env['account.analytic.line'].read_group(
1493            domain,
1494            ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False
1495        )
1496
1497        # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines
1498        # browse so lines and product uoms here to make them share the same prefetch
1499        lines = self.browse([item['so_line'][0] for item in data])
1500        lines_map = {line.id: line for line in lines}
1501        product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']]
1502        product_uom_map = {uom.id: uom for uom in self.env['uom.uom'].browse(product_uom_ids)}
1503        for item in data:
1504            if not item['product_uom_id']:
1505                continue
1506            so_line_id = item['so_line'][0]
1507            so_line = lines_map[so_line_id]
1508            result.setdefault(so_line_id, 0.0)
1509            uom = product_uom_map.get(item['product_uom_id'][0])
1510            if so_line.product_uom.category_id == uom.category_id:
1511                qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom, rounding_method='HALF-UP')
1512            else:
1513                qty = item['unit_amount']
1514            result[so_line_id] += qty
1515
1516        return result
1517
1518    @api.onchange('qty_delivered')
1519    def _inverse_qty_delivered(self):
1520        """ When writing on qty_delivered, if the value should be modify manually (`qty_delivered_method` = 'manual' only),
1521            then we put the value in `qty_delivered_manual`. Otherwise, `qty_delivered_manual` should be False since the
1522            delivered qty is automatically compute by other mecanisms.
1523        """
1524        for line in self:
1525            if line.qty_delivered_method == 'manual':
1526                line.qty_delivered_manual = line.qty_delivered
1527            else:
1528                line.qty_delivered_manual = 0.0
1529
1530    @api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state', 'invoice_lines.move_id.move_type')
1531    def _compute_untaxed_amount_invoiced(self):
1532        """ Compute the untaxed amount already invoiced from the sale order line, taking the refund attached
1533            the so line into account. This amount is computed as
1534                SUM(inv_line.price_subtotal) - SUM(ref_line.price_subtotal)
1535            where
1536                `inv_line` is a customer invoice line linked to the SO line
1537                `ref_line` is a customer credit note (refund) line linked to the SO line
1538        """
1539        for line in self:
1540            amount_invoiced = 0.0
1541            for invoice_line in line.invoice_lines:
1542                if invoice_line.move_id.state == 'posted':
1543                    invoice_date = invoice_line.move_id.invoice_date or fields.Date.today()
1544                    if invoice_line.move_id.move_type == 'out_invoice':
1545                        amount_invoiced += invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date)
1546                    elif invoice_line.move_id.move_type == 'out_refund':
1547                        amount_invoiced -= invoice_line.currency_id._convert(invoice_line.price_subtotal, line.currency_id, line.company_id, invoice_date)
1548            line.untaxed_amount_invoiced = amount_invoiced
1549
1550    @api.depends('state', 'price_reduce', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty')
1551    def _compute_untaxed_amount_to_invoice(self):
1552        """ Total of remaining amount to invoice on the sale order line (taxes excl.) as
1553                total_sol - amount already invoiced
1554            where Total_sol depends on the invoice policy of the product.
1555
1556            Note: Draft invoice are ignored on purpose, the 'to invoice' amount should
1557            come only from the SO lines.
1558        """
1559        for line in self:
1560            amount_to_invoice = 0.0
1561            if line.state in ['sale', 'done']:
1562                # Note: do not use price_subtotal field as it returns zero when the ordered quantity is
1563                # zero. It causes problem for expense line (e.i.: ordered qty = 0, deli qty = 4,
1564                # price_unit = 20 ; subtotal is zero), but when you can invoice the line, you see an
1565                # amount and not zero. Since we compute untaxed amount, we can use directly the price
1566                # reduce (to include discount) without using `compute_all()` method on taxes.
1567                price_subtotal = 0.0
1568                uom_qty_to_consider = line.qty_delivered if line.product_id.invoice_policy == 'delivery' else line.product_uom_qty
1569                price_reduce = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
1570                price_subtotal = price_reduce * uom_qty_to_consider
1571                if len(line.tax_id.filtered(lambda tax: tax.price_include)) > 0:
1572                    # As included taxes are not excluded from the computed subtotal, `compute_all()` method
1573                    # has to be called to retrieve the subtotal without them.
1574                    # `price_reduce_taxexcl` cannot be used as it is computed from `price_subtotal` field. (see upper Note)
1575                    price_subtotal = line.tax_id.compute_all(
1576                        price_reduce,
1577                        currency=line.order_id.currency_id,
1578                        quantity=uom_qty_to_consider,
1579                        product=line.product_id,
1580                        partner=line.order_id.partner_shipping_id)['total_excluded']
1581
1582                if any(line.invoice_lines.mapped(lambda l: l.discount != line.discount)):
1583                    # In case of re-invoicing with different discount we try to calculate manually the
1584                    # remaining amount to invoice
1585                    amount = 0
1586                    for l in line.invoice_lines:
1587                        if len(l.tax_ids.filtered(lambda tax: tax.price_include)) > 0:
1588                            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']
1589                        else:
1590                            amount += l.currency_id._convert(l.price_unit, line.currency_id, line.company_id, l.date or fields.Date.today(), round=False) * l.quantity
1591
1592                    amount_to_invoice = max(price_subtotal - amount, 0)
1593                else:
1594                    amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced
1595
1596            line.untaxed_amount_to_invoice = amount_to_invoice
1597
1598    def _get_invoice_line_sequence(self, new=0, old=0):
1599        """
1600        Method intended to be overridden in third-party module if we want to prevent the resequencing
1601        of invoice lines.
1602
1603        :param int new:   the new line sequence
1604        :param int old:   the old line sequence
1605
1606        :return:          the sequence of the SO line, by default the new one.
1607        """
1608        return new or old
1609
1610    def _prepare_invoice_line(self, **optional_values):
1611        """
1612        Prepare the dict of values to create the new invoice line for a sales order line.
1613
1614        :param qty: float quantity to invoice
1615        :param optional_values: any parameter that should be added to the returned invoice line
1616        """
1617        self.ensure_one()
1618        res = {
1619            'display_type': self.display_type,
1620            'sequence': self.sequence,
1621            'name': self.name,
1622            'product_id': self.product_id.id,
1623            'product_uom_id': self.product_uom.id,
1624            'quantity': self.qty_to_invoice,
1625            'discount': self.discount,
1626            'price_unit': self.price_unit,
1627            'tax_ids': [(6, 0, self.tax_id.ids)],
1628            'analytic_account_id': self.order_id.analytic_account_id.id,
1629            'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)],
1630            'sale_line_ids': [(4, self.id)],
1631        }
1632        if optional_values:
1633            res.update(optional_values)
1634        if self.display_type:
1635            res['account_id'] = False
1636        return res
1637
1638    def _prepare_procurement_values(self, group_id=False):
1639        """ Prepare specific key for moves or other components that will be created from a stock rule
1640        comming from a sale order line. This method could be override in order to add other custom key that could
1641        be used in move/po creation.
1642        """
1643        return {}
1644
1645    def _get_display_price(self, product):
1646        # TO DO: move me in master/saas-16 on sale.order
1647        # awa: don't know if it's still the case since we need the "product_no_variant_attribute_value_ids" field now
1648        # to be able to compute the full price
1649
1650        # it is possible that a no_variant attribute is still in a variant if
1651        # the type of the attribute has been changed after creation.
1652        no_variant_attributes_price_extra = [
1653            ptav.price_extra for ptav in self.product_no_variant_attribute_value_ids.filtered(
1654                lambda ptav:
1655                    ptav.price_extra and
1656                    ptav not in product.product_template_attribute_value_ids
1657            )
1658        ]
1659        if no_variant_attributes_price_extra:
1660            product = product.with_context(
1661                no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra)
1662            )
1663
1664        if self.order_id.pricelist_id.discount_policy == 'with_discount':
1665            return product.with_context(pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id).price
1666        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)
1667
1668        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)
1669        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)
1670        if currency != self.order_id.pricelist_id.currency_id:
1671            base_price = currency._convert(
1672                base_price, self.order_id.pricelist_id.currency_id,
1673                self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today())
1674        # negative discounts (= surcharge) are included in the display price
1675        return max(base_price, final_price)
1676
1677    @api.onchange('product_id')
1678    def product_id_change(self):
1679        if not self.product_id:
1680            return
1681        valid_values = self.product_id.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids
1682        # remove the is_custom values that don't belong to this template
1683        for pacv in self.product_custom_attribute_value_ids:
1684            if pacv.custom_product_template_attribute_value_id not in valid_values:
1685                self.product_custom_attribute_value_ids -= pacv
1686
1687        # remove the no_variant attributes that don't belong to this template
1688        for ptav in self.product_no_variant_attribute_value_ids:
1689            if ptav._origin not in valid_values:
1690                self.product_no_variant_attribute_value_ids -= ptav
1691
1692        vals = {}
1693        if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id):
1694            vals['product_uom'] = self.product_id.uom_id
1695            vals['product_uom_qty'] = self.product_uom_qty or 1.0
1696
1697        product = self.product_id.with_context(
1698            lang=get_lang(self.env, self.order_id.partner_id.lang).code,
1699            partner=self.order_id.partner_id,
1700            quantity=vals.get('product_uom_qty') or self.product_uom_qty,
1701            date=self.order_id.date_order,
1702            pricelist=self.order_id.pricelist_id.id,
1703            uom=self.product_uom.id
1704        )
1705
1706        vals.update(name=self.get_sale_order_line_multiline_description_sale(product))
1707
1708        self._compute_tax_id()
1709
1710        if self.order_id.pricelist_id and self.order_id.partner_id:
1711            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)
1712        self.update(vals)
1713
1714        title = False
1715        message = False
1716        result = {}
1717        warning = {}
1718        if product.sale_line_warn != 'no-message':
1719            title = _("Warning for %s", product.name)
1720            message = product.sale_line_warn_msg
1721            warning['title'] = title
1722            warning['message'] = message
1723            result = {'warning': warning}
1724            if product.sale_line_warn == 'block':
1725                self.product_id = False
1726
1727        return result
1728
1729    @api.onchange('product_uom', 'product_uom_qty')
1730    def product_uom_change(self):
1731        if not self.product_uom or not self.product_id:
1732            self.price_unit = 0.0
1733            return
1734        if self.order_id.pricelist_id and self.order_id.partner_id:
1735            product = self.product_id.with_context(
1736                lang=self.order_id.partner_id.lang,
1737                partner=self.order_id.partner_id,
1738                quantity=self.product_uom_qty,
1739                date=self.order_id.date_order,
1740                pricelist=self.order_id.pricelist_id.id,
1741                uom=self.product_uom.id,
1742                fiscal_position=self.env.context.get('fiscal_position')
1743            )
1744            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)
1745
1746    def name_get(self):
1747        result = []
1748        for so_line in self.sudo():
1749            name = '%s - %s' % (so_line.order_id.name, so_line.name and so_line.name.split('\n')[0] or so_line.product_id.name)
1750            if so_line.order_partner_id.ref:
1751                name = '%s (%s)' % (name, so_line.order_partner_id.ref)
1752            result.append((so_line.id, name))
1753        return result
1754
1755    @api.model
1756    def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
1757        if operator in ('ilike', 'like', '=', '=like', '=ilike'):
1758            args = expression.AND([
1759                args or [],
1760                ['|', ('order_id.name', operator, name), ('name', operator, name)]
1761            ])
1762        return super(SaleOrderLine, self)._name_search(name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
1763
1764    def _check_line_unlink(self):
1765        """
1766        Check wether a line can be deleted or not.
1767
1768        Lines cannot be deleted if the order is confirmed; downpayment
1769        lines who have not yet been invoiced bypass that exception.
1770        :rtype: recordset sale.order.line
1771        :returns: set of lines that cannot be deleted
1772        """
1773        return self.filtered(lambda line: line.state in ('sale', 'done') and (line.invoice_lines or not line.is_downpayment))
1774
1775    def unlink(self):
1776        if self._check_line_unlink():
1777            raise UserError(_('You can not remove an order line once the sales order is confirmed.\nYou should rather set the quantity to 0.'))
1778        return super(SaleOrderLine, self).unlink()
1779
1780    def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id):
1781        """Retrieve the price before applying the pricelist
1782            :param obj product: object of current product record
1783            :parem float qty: total quentity of product
1784            :param tuple price_and_rule: tuple(price, suitable_rule) coming from pricelist computation
1785            :param obj uom: unit of measure of current order line
1786            :param integer pricelist_id: pricelist id of sales order"""
1787        PricelistItem = self.env['product.pricelist.item']
1788        field_name = 'lst_price'
1789        currency_id = None
1790        product_currency = product.currency_id
1791        if rule_id:
1792            pricelist_item = PricelistItem.browse(rule_id)
1793            if pricelist_item.pricelist_id.discount_policy == 'without_discount':
1794                while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id and pricelist_item.base_pricelist_id.discount_policy == 'without_discount':
1795                    price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id)
1796                    pricelist_item = PricelistItem.browse(rule_id)
1797
1798            if pricelist_item.base == 'standard_price':
1799                field_name = 'standard_price'
1800                product_currency = product.cost_currency_id
1801            elif pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id:
1802                field_name = 'price'
1803                product = product.with_context(pricelist=pricelist_item.base_pricelist_id.id)
1804                product_currency = pricelist_item.base_pricelist_id.currency_id
1805            currency_id = pricelist_item.pricelist_id.currency_id
1806
1807        if not currency_id:
1808            currency_id = product_currency
1809            cur_factor = 1.0
1810        else:
1811            if currency_id.id == product_currency.id:
1812                cur_factor = 1.0
1813            else:
1814                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())
1815
1816        product_uom = self.env.context.get('uom') or product.uom_id.id
1817        if uom and uom.id != product_uom:
1818            # the unit price is in a different uom
1819            uom_factor = uom._compute_price(1.0, product.uom_id)
1820        else:
1821            uom_factor = 1.0
1822
1823        return product[field_name] * uom_factor * cur_factor, currency_id
1824
1825    def _get_protected_fields(self):
1826        return [
1827            'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty',
1828            'tax_id', 'analytic_tag_ids'
1829        ]
1830
1831    def _onchange_product_id_set_customer_lead(self):
1832        pass
1833
1834    @api.onchange('product_id', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id')
1835    def _onchange_discount(self):
1836        if not (self.product_id and self.product_uom and
1837                self.order_id.partner_id and self.order_id.pricelist_id and
1838                self.order_id.pricelist_id.discount_policy == 'without_discount' and
1839                self.env.user.has_group('product.group_discount_per_so_line')):
1840            return
1841
1842        self.discount = 0.0
1843        product = self.product_id.with_context(
1844            lang=self.order_id.partner_id.lang,
1845            partner=self.order_id.partner_id,
1846            quantity=self.product_uom_qty,
1847            date=self.order_id.date_order,
1848            pricelist=self.order_id.pricelist_id.id,
1849            uom=self.product_uom.id,
1850            fiscal_position=self.env.context.get('fiscal_position')
1851        )
1852
1853        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)
1854
1855        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)
1856        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)
1857
1858        if new_list_price != 0:
1859            if self.order_id.pricelist_id.currency_id != currency:
1860                # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency
1861                new_list_price = currency._convert(
1862                    new_list_price, self.order_id.pricelist_id.currency_id,
1863                    self.order_id.company_id or self.env.company, self.order_id.date_order or fields.Date.today())
1864            discount = (new_list_price - price) / new_list_price * 100
1865            if (discount > 0 and new_list_price > 0) or (discount < 0 and new_list_price < 0):
1866                self.discount = discount
1867
1868    def _is_delivery(self):
1869        self.ensure_one()
1870        return False
1871
1872    def get_sale_order_line_multiline_description_sale(self, product):
1873        """ Compute a default multiline description for this sales order line.
1874
1875        In most cases the product description is enough but sometimes we need to append information that only
1876        exists on the sale order line itself.
1877        e.g:
1878        - custom attributes and attributes that don't create variants, both introduced by the "product configurator"
1879        - in event_sale we need to know specifically the sales order line as well as the product to generate the name:
1880          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).
1881        """
1882        return product.get_product_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants()
1883
1884    def _get_sale_order_line_multiline_description_variants(self):
1885        """When using no_variant attributes or is_custom values, the product
1886        itself is not sufficient to create the description: we need to add
1887        information about those special attributes and values.
1888
1889        :return: the description related to special variant attributes/values
1890        :rtype: string
1891        """
1892        if not self.product_custom_attribute_value_ids and not self.product_no_variant_attribute_value_ids:
1893            return ""
1894
1895        name = "\n"
1896
1897        custom_ptavs = self.product_custom_attribute_value_ids.custom_product_template_attribute_value_id
1898        no_variant_ptavs = self.product_no_variant_attribute_value_ids._origin
1899
1900        # display the no_variant attributes, except those that are also
1901        # displayed by a custom (avoid duplicate description)
1902        for ptav in (no_variant_ptavs - custom_ptavs):
1903            name += "\n" + ptav.with_context(lang=self.order_id.partner_id.lang).display_name
1904
1905        # Sort the values according to _order settings, because it doesn't work for virtual records in onchange
1906        custom_values = sorted(self.product_custom_attribute_value_ids, key=lambda r: (r.custom_product_template_attribute_value_id.id, r.id))
1907        # display the is_custom values
1908        for pacv in custom_values:
1909            name += "\n" + pacv.with_context(lang=self.order_id.partner_id.lang).display_name
1910
1911        return name
1912