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