# -*- coding: utf-8 -*- # Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details. import uuid from itertools import groupby from datetime import datetime, timedelta from werkzeug.urls import url_encode from flectra import api, fields, models, _ from flectra.exceptions import UserError, AccessError from flectra.osv import expression from flectra.tools import float_is_zero, float_compare, DEFAULT_SERVER_DATETIME_FORMAT from flectra.tools.misc import formatLang from flectra.exceptions import ValidationError from flectra.addons import decimal_precision as dp class SaleOrder(models.Model): _name = "sale.order" _inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin', 'ir.branch.company.mixin'] _description = "Quotation" _order = 'date_order desc, id desc' @api.depends('order_line.price_total') def _amount_all(self): """ Compute the total amounts of the SO. """ for order in self: amount_untaxed = amount_tax = 0.0 for line in order.order_line: amount_untaxed += line.price_subtotal amount_tax += line.price_tax order.update({ 'amount_untaxed': order.pricelist_id.currency_id.round(amount_untaxed), 'amount_tax': order.pricelist_id.currency_id.round(amount_tax), 'amount_total': amount_untaxed + amount_tax, }) @api.depends('state', 'order_line.invoice_status') def _get_invoiced(self): """ Compute the invoice status of a SO. Possible statuses: - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to invoice. This is also hte default value if the conditions of no other status is met. - to invoice: if any SO line is 'to invoice', the whole SO is 'to invoice' - invoiced: if all SO lines are invoiced, the SO is invoiced. - upselling: if all SO lines are invoiced or upselling, the status is upselling. The invoice_ids are obtained thanks to the invoice lines of the SO lines, and we also search for possible refunds created directly from existing invoices. This is necessary since such a refund is not directly linked to the SO. """ for order in self: invoice_ids = order.order_line.mapped('invoice_lines').mapped('invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund']) # Search for invoices which have been 'cancelled' (filter_refund = 'modify' in # 'account.invoice.refund') # use like as origin may contains multiple references (e.g. 'SO01, SO02') refunds = invoice_ids.search([('origin', 'like', order.name)]).filtered(lambda r: r.type in ['out_invoice', 'out_refund']) invoice_ids |= refunds.filtered(lambda r: order.name in [origin.strip() for origin in r.origin.split(',')]) # Search for refunds as well refund_ids = self.env['account.invoice'].browse() if invoice_ids: for inv in invoice_ids: refund_ids += refund_ids.search([('type', '=', 'out_refund'), ('origin', '=', inv.number), ('origin', '!=', False), ('journal_id', '=', inv.journal_id.id)]) # Ignore the status of the deposit product deposit_product_id = self.env['sale.advance.payment.inv']._default_product_id() line_invoice_status = [line.invoice_status for line in order.order_line if line.product_id != deposit_product_id] if order.state not in ('sale', 'done'): invoice_status = 'no' elif any(invoice_status == 'to invoice' for invoice_status in line_invoice_status): invoice_status = 'to invoice' elif all(invoice_status == 'invoiced' for invoice_status in line_invoice_status): invoice_status = 'invoiced' elif all(invoice_status in ['invoiced', 'upselling'] for invoice_status in line_invoice_status): invoice_status = 'upselling' else: invoice_status = 'no' order.update({ 'invoice_count': len(set(invoice_ids.ids + refund_ids.ids)), 'invoice_ids': invoice_ids.ids + refund_ids.ids, 'invoice_status': invoice_status }) @api.model def get_empty_list_help(self, help): if help: return '

%s

' % (help) return super(SaleOrder, self).get_empty_list_help(help) def _get_default_access_token(self): return str(uuid.uuid4()) @api.model def _default_note(self): return self.env['ir.config_parameter'].sudo().get_param('sale.use_sale_note') and self.env.user.company_id.sale_note or '' @api.model def _get_default_team(self): return self.env['crm.team']._get_default_team_id() @api.onchange('fiscal_position_id') def _compute_tax_id(self): """ Trigger the recompute of the taxes if the fiscal position is changed on the SO. """ for order in self: order.order_line._compute_tax_id() name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New')) origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.") client_order_ref = fields.Char(string='Customer Reference', copy=False) access_token = fields.Char( 'Security Token', copy=False, default=_get_default_access_token) state = fields.Selection([ ('draft', 'Quotation'), ('sent', 'Quotation Sent'), ('sale', 'Sales Order'), ('done', 'Locked'), ('cancel', 'Cancelled'), ], string='Status', readonly=True, copy=False, index=True, track_visibility='onchange', default='draft') date_order = fields.Datetime(string='Order Date', required=True, readonly=True, index=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, copy=False, default=fields.Datetime.now) validity_date = fields.Date(string='Expiration Date', readonly=True, copy=False, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Manually set the expiration date of your quotation (offer), or it will set the date automatically based on the template if online quotation is installed.") is_expired = fields.Boolean(compute='_compute_is_expired', string="Is expired") create_date = fields.Datetime(string='Creation Date', readonly=True, index=True, help="Date on which sales order is created.") confirmation_date = fields.Datetime(string='Confirmation Date', readonly=True, index=True, help="Date on which the sales order is confirmed.", oldname="date_confirm") user_id = fields.Many2one('res.users', string='Salesperson', index=True, track_visibility='onchange', default=lambda self: self.env.user) partner_id = fields.Many2one('res.partner', string='Customer', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, required=True, change_default=True, index=True, track_visibility='always') partner_invoice_id = fields.Many2one('res.partner', string='Invoice Address', readonly=True, required=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Invoice address for current sales order.") partner_shipping_id = fields.Many2one('res.partner', string='Delivery Address', readonly=True, required=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Delivery address for current sales order.") pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order.") currency_id = fields.Many2one("res.currency", related='pricelist_id.currency_id', string="Currency", readonly=True, required=True) analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False, oldname='project_id') order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True) invoice_count = fields.Integer(string='# of Invoices', compute='_get_invoiced', readonly=True) invoice_ids = fields.Many2many("account.invoice", string='Invoices', compute="_get_invoiced", readonly=True, copy=False) invoice_status = fields.Selection([ ('upselling', 'Upselling Opportunity'), ('invoiced', 'Fully Invoiced'), ('to invoice', 'To Invoice'), ('no', 'Nothing to Invoice') ], string='Invoice Status', compute='_get_invoiced', store=True, readonly=True) note = fields.Text('Terms and conditions', default=_default_note) amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, compute='_amount_all', track_visibility='onchange') amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all') amount_total = fields.Monetary(string='Total', store=True, readonly=True, compute='_amount_all', track_visibility='always') payment_term_id = fields.Many2one('account.payment.term', string='Payment Terms', oldname='payment_term') fiscal_position_id = fields.Many2one('account.fiscal.position', oldname='fiscal_position', string='Fiscal Position') company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('sale.order')) team_id = fields.Many2one('crm.team', 'Sales Channel', change_default=True, default=_get_default_team, oldname='section_id') product_id = fields.Many2one('product.product', related='order_line.product_id', string='Product') @api.multi @api.constrains('branch_id', 'company_id') def _check_company_branch(self): for order in self: if order.branch_id and order.company_id != order.branch_id.company_id: raise ValidationError(_( 'Configuration Error of Company:\n' 'The Sales Order Company (%s) and the Company (%s) of ' 'Branch must be the same!') % ( order.company_id.name, order.branch_id.company_id.name)) def _compute_portal_url(self): super(SaleOrder, self)._compute_portal_url() for order in self: order.portal_url = '/my/orders/%s' % (order.id) def _compute_is_expired(self): now = datetime.now() for order in self: if order.validity_date and fields.Datetime.from_string(order.validity_date) < now: order.is_expired = True else: order.is_expired = False @api.model def _get_customer_lead(self, product_tmpl_id): return False @api.multi def unlink(self): for order in self: if order.state not in ('draft', 'cancel'): raise UserError(_('You can not delete a sent quotation or a sales order! Try to cancel it before.')) return super(SaleOrder, self).unlink() @api.multi def _track_subtype(self, init_values): self.ensure_one() if 'state' in init_values and self.state == 'sale': return 'sale.mt_order_confirmed' elif 'state' in init_values and self.state == 'sent': return 'sale.mt_order_sent' return super(SaleOrder, self)._track_subtype(init_values) @api.multi @api.onchange('partner_shipping_id', 'partner_id') def onchange_partner_shipping_id(self): """ Trigger the change of fiscal position when the shipping address is modified. """ self.fiscal_position_id = self.env['account.fiscal.position'].get_fiscal_position(self.partner_id.id, self.partner_shipping_id.id) return {} @api.multi @api.onchange('partner_id') def onchange_partner_id(self): """ Update the following fields when the partner is changed: - Pricelist - Payment terms - Invoice address - Delivery address """ if not self.partner_id: self.update({ 'partner_invoice_id': False, 'partner_shipping_id': False, 'payment_term_id': False, 'fiscal_position_id': False, }) return addr = self.partner_id.address_get(['delivery', 'invoice']) values = { 'pricelist_id': self.partner_id.property_product_pricelist and self.partner_id.property_product_pricelist.id or False, 'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False, 'partner_invoice_id': addr['invoice'], 'partner_shipping_id': addr['delivery'], 'user_id': self.partner_id.user_id.id or self.env.uid } if self.env['ir.config_parameter'].sudo().get_param('sale.use_sale_note') and self.env.user.company_id.sale_note: values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.sale_note if self.partner_id.team_id: values['team_id'] = self.partner_id.team_id.id self.update(values) @api.onchange('partner_id') def onchange_partner_id_warning(self): if not self.partner_id: return warning = {} title = False message = False partner = self.partner_id # If partner has no warning, check its company if partner.sale_warn == 'no-message' and partner.parent_id: partner = partner.parent_id if partner.sale_warn != 'no-message': # Block if partner only has warning but parent company is blocked if partner.sale_warn != 'block' and partner.parent_id and partner.parent_id.sale_warn == 'block': partner = partner.parent_id title = ("Warning for %s") % partner.name message = partner.sale_warn_msg warning = { 'title': title, 'message': message, } if partner.sale_warn == 'block': self.update({'partner_id': False, 'partner_invoice_id': False, 'partner_shipping_id': False, 'pricelist_id': False}) return {'warning': warning} if warning: return {'warning': warning} @api.model def create(self, vals): if vals.get('name', _('New')) == _('New'): if 'company_id' in vals: vals['name'] = self.env['ir.sequence'].with_context(force_company=vals['company_id']).next_by_code('sale.order') or _('New') else: vals['name'] = self.env['ir.sequence'].next_by_code('sale.order') or _('New') # Makes sure partner_invoice_id', 'partner_shipping_id' and 'pricelist_id' are defined if any(f not in vals for f in ['partner_invoice_id', 'partner_shipping_id', 'pricelist_id']): partner = self.env['res.partner'].browse(vals.get('partner_id')) addr = partner.address_get(['delivery', 'invoice']) vals['partner_invoice_id'] = vals.setdefault('partner_invoice_id', addr['invoice']) vals['partner_shipping_id'] = vals.setdefault('partner_shipping_id', addr['delivery']) vals['pricelist_id'] = vals.setdefault('pricelist_id', partner.property_product_pricelist and partner.property_product_pricelist.id) result = super(SaleOrder, self).create(vals) return result @api.multi def copy_data(self, default=None): if default is None: default = {} if 'order_line' not in default: default['order_line'] = [(0, 0, line.copy_data()[0]) for line in self.order_line.filtered(lambda l: not l.is_downpayment)] return super(SaleOrder, self).copy_data(default) @api.multi def name_get(self): if self._context.get('sale_show_partner_name'): res = [] for order in self: name = order.name if order.partner_id.name: name = '%s - %s' % (name, order.partner_id.name) res.append((order.id, name)) return res return super(SaleOrder, self).name_get() @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): if self._context.get('sale_show_partner_name'): if operator in ('ilike', 'like', '=', '=like', '=ilike'): domain = expression.AND([ args or [], ['|', ('name', operator, name), ('partner_id.name', operator, name)] ]) return self.search(domain, limit=limit).name_get() return super(SaleOrder, self).name_search(name, args, operator, limit) @api.model_cr_context def _init_column(self, column_name): """ Initialize the value of the given column for existing rows. Overridden here because we need to generate different access tokens and by default _init_column calls the default method once and applies it for every record. """ if column_name != 'access_token': super(SaleOrder, self)._init_column(column_name) else: query = """UPDATE %(table_name)s SET %(column_name)s = md5(md5(random()::varchar || id::varchar) || clock_timestamp()::varchar)::uuid::varchar WHERE %(column_name)s IS NULL """ % {'table_name': self._table, 'column_name': column_name} self.env.cr.execute(query) def _generate_access_token(self): for order in self: order.access_token = self._get_default_access_token() @api.multi def _prepare_invoice(self): """ Prepare the dict of values to create the new invoice for a sales order. This method may be overridden to implement custom invoice generation (making sure to call super() to establish a clean extension chain). """ self.ensure_one() journal_id = self.env['account.invoice'].default_get(['journal_id'])['journal_id'] if not journal_id: raise UserError(_('Please define an accounting sales journal for this company.')) invoice_vals = { 'name': self.client_order_ref or '', 'origin': self.name, 'branch_id': self.branch_id and self.branch_id.id, 'type': 'out_invoice', 'account_id': self.partner_invoice_id.property_account_receivable_id.id, 'partner_id': self.partner_invoice_id.id, 'partner_shipping_id': self.partner_shipping_id.id, 'journal_id': journal_id, 'currency_id': self.pricelist_id.currency_id.id, 'comment': self.note, 'payment_term_id': self.payment_term_id.id, 'fiscal_position_id': self.fiscal_position_id.id or self.partner_invoice_id.property_account_position_id.id, 'company_id': self.company_id.id, 'user_id': self.user_id and self.user_id.id, 'team_id': self.team_id.id } return invoice_vals @api.multi def print_quotation(self): self.filtered(lambda s: s.state == 'draft').write({'state': 'sent'}) return self.env.ref('sale.action_report_saleorder').report_action(self) @api.multi def action_view_invoice(self): invoices = self.mapped('invoice_ids') action = self.env.ref('account.action_invoice_tree1').read()[0] if len(invoices) > 1: action['domain'] = [('id', 'in', invoices.ids)] elif len(invoices) == 1: action['views'] = [(self.env.ref('account.invoice_form').id, 'form')] action['res_id'] = invoices.ids[0] else: action = {'type': 'ir.actions.act_window_close'} return action @api.multi def action_invoice_create(self, grouped=False, final=False): """ Create the invoice associated to the SO. :param grouped: if True, invoices are grouped by SO id. If False, invoices are grouped by (partner_invoice_id, currency) :param final: if True, refunds will be generated if necessary :returns: list of created invoices """ inv_obj = self.env['account.invoice'] precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') invoices = {} references = {} for order in self: group_key = order.id if grouped else (order.partner_invoice_id.id, order.currency_id.id) for line in order.order_line.sorted(key=lambda l: l.qty_to_invoice < 0): if float_is_zero(line.qty_to_invoice, precision_digits=precision): continue if group_key not in invoices: inv_data = order._prepare_invoice() invoice = inv_obj.create(inv_data) references[invoice] = order invoices[group_key] = invoice elif group_key in invoices: vals = {} if order.name not in invoices[group_key].origin.split(', '): vals['origin'] = invoices[group_key].origin + ', ' + order.name if order.client_order_ref and order.client_order_ref not in invoices[group_key].name.split(', ') and order.client_order_ref != invoices[group_key].name: vals['name'] = invoices[group_key].name + ', ' + order.client_order_ref invoices[group_key].write(vals) if line.qty_to_invoice > 0: line.invoice_line_create(invoices[group_key].id, line.qty_to_invoice) elif line.qty_to_invoice < 0 and final: line.invoice_line_create(invoices[group_key].id, line.qty_to_invoice) if references.get(invoices.get(group_key)): if order not in references[invoices[group_key]]: references[invoice] = references[invoice] | order if not invoices: raise UserError(_('There is no invoiceable line.')) for invoice in invoices.values(): if not invoice.invoice_line_ids: raise UserError(_('There is no invoiceable line.')) # If invoice is negative, do a refund invoice instead if invoice.amount_untaxed < 0: invoice.type = 'out_refund' for line in invoice.invoice_line_ids: line.quantity = -line.quantity # Use additional field helper function (for account extensions) for line in invoice.invoice_line_ids: line._set_additional_fields(invoice) # Necessary to force computation of taxes. In account_invoice, they are triggered # by onchanges, which are not triggered when doing a create. invoice.compute_taxes() invoice.message_post_with_view('mail.message_origin_link', values={'self': invoice, 'origin': references[invoice]}, subtype_id=self.env.ref('mail.mt_note').id) return [inv.id for inv in invoices.values()] @api.multi def action_draft(self): orders = self.filtered(lambda s: s.state in ['cancel', 'sent']) return orders.write({ 'state': 'draft', }) @api.multi def action_cancel(self): return self.write({'state': 'cancel'}) @api.multi def action_quotation_send(self): ''' This function opens a window to compose an email, with the edi sale template message loaded by default ''' self.ensure_one() ir_model_data = self.env['ir.model.data'] try: template_id = ir_model_data.get_object_reference('sale', 'email_template_edi_sale')[1] except ValueError: template_id = False try: compose_form_id = ir_model_data.get_object_reference('mail', 'email_compose_message_wizard_form')[1] except ValueError: compose_form_id = False ctx = { 'default_model': 'sale.order', 'default_res_id': self.ids[0], 'default_use_template': bool(template_id), 'default_template_id': template_id, 'default_composition_mode': 'comment', 'mark_so_as_sent': True, 'custom_layout': "sale.mail_template_data_notification_email_sale_order", 'proforma': self.env.context.get('proforma', False), 'force_email': True } return { 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'mail.compose.message', 'views': [(compose_form_id, 'form')], 'view_id': compose_form_id, 'target': 'new', 'context': ctx, } @api.multi def force_quotation_send(self): for order in self: email_act = order.action_quotation_send() if email_act and email_act.get('context'): email_ctx = email_act['context'] email_ctx.update(default_email_from=order.company_id.email) order.with_context(email_ctx).message_post_with_template(email_ctx.get('default_template_id')) return True @api.multi def action_done(self): return self.write({'state': 'done'}) @api.multi def action_unlock(self): self.write({'state': 'sale'}) @api.multi def _action_confirm(self): for order in self.filtered(lambda order: order.partner_id not in order.message_partner_ids): order.message_subscribe([order.partner_id.id]) self.write({ 'state': 'sale', 'confirmation_date': fields.Datetime.now() }) if self.env.context.get('send_email'): self.force_quotation_send() # create an analytic account if at least an expense product if any([expense_policy != 'no' for expense_policy in self.order_line.mapped('product_id.expense_policy')]): if not self.analytic_account_id: self._create_analytic_account() return True @api.multi def action_confirm(self): self._action_confirm() if self.env['ir.config_parameter'].sudo().get_param('sale.auto_done_setting'): self.action_done() return True @api.multi def _create_analytic_account(self, prefix=None): for order in self: name = order.name if prefix: name = prefix + ": " + order.name analytic = self.env['account.analytic.account'].create({ 'name': name, 'code': order.client_order_ref, 'branch_id': order.branch_id and order.branch_id.id, 'company_id': order.company_id.id, 'partner_id': order.partner_id.id }) order.analytic_account_id = analytic @api.multi def order_lines_layouted(self): """ Returns this order lines classified by sale_layout_category and separated in pages according to the category pagebreaks. Used to render the report. """ self.ensure_one() report_pages = [[]] for category, lines in groupby(self.order_line, lambda l: l.layout_category_id): # If last added category induced a pagebreak, this one will be on a new page if report_pages[-1] and report_pages[-1][-1]['pagebreak']: report_pages.append([]) # Append category to current report page report_pages[-1].append({ 'name': category and category.name or 'Uncategorized', 'subtotal': category and category.subtotal, 'pagebreak': category and category.pagebreak, 'lines': list(lines) }) return report_pages @api.multi def _get_tax_amount_by_group(self): self.ensure_one() res = {} for line in self.order_line: base_tax = 0 for tax in line.tax_id: group = tax.tax_group_id res.setdefault(group, {'amount': 0.0, 'base': 0.0}) # FORWARD-PORT UP TO SAAS-17 price_reduce = line.price_unit * (1.0 - line.discount / 100.0) taxes = tax.compute_all(price_reduce + base_tax, quantity=line.product_uom_qty, product=line.product_id, partner=self.partner_shipping_id)['taxes'] for t in taxes: res[group]['amount'] += t['amount'] res[group]['base'] += t['base'] if tax.include_base_amount: base_tax += tax.compute_all(price_reduce + base_tax, quantity=1, product=line.product_id, partner=self.partner_shipping_id)['taxes'][0]['amount'] res = sorted(res.items(), key=lambda l: l[0].sequence) res = [(l[0].name, l[1]['amount'], l[1]['base'], len(res)) for l in res] return res @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the online order for portal users or if force_website=True in the context. """ # TDE note: read access on sales order to portal users granted to followed sales orders self.ensure_one() if self.state != 'cancel' and (self.state != 'draft' or self.env.context.get('mark_so_as_sent')): user, record = self.env.user, self if access_uid: user = self.env['res.users'].sudo().browse(access_uid) record = self.sudo(user) if user.share or self.env.context.get('force_website'): try: record.check_access_rule('read') except AccessError: if self.env.context.get('force_website'): return { 'type': 'ir.actions.act_url', 'url': '/my/orders/%s' % self.id, 'target': 'self', 'res_id': self.id, } else: pass else: return { 'type': 'ir.actions.act_url', 'url': '/my/orders/%s?access_token=%s' % (self.id, self.access_token), 'target': 'self', 'res_id': self.id, } return super(SaleOrder, self).get_access_action(access_uid) def get_mail_url(self): return self.get_share_url() def get_portal_confirmation_action(self): return self.env['ir.config_parameter'].sudo().get_param('sale.sale_portal_confirmation_options', default='none') @api.multi def _notification_recipients(self, message, groups): groups = super(SaleOrder, self)._notification_recipients(message, groups) self.ensure_one() if self.state not in ('draft', 'cancel'): for group_name, group_method, group_data in groups: if group_name == 'customer': continue group_data['has_button_access'] = True return groups class SaleOrderLine(models.Model): _name = 'sale.order.line' _description = 'Sales Order Line' _order = 'order_id, layout_category_id, sequence, id' @api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced') def _compute_invoice_status(self): """ Compute the invoice status of a SO line. Possible statuses: - no: if the SO is not in status 'sale' or 'done', we consider that there is nothing to invoice. This is also hte default value if the conditions of no other status is met. - to invoice: we refer to the quantity to invoice of the line. Refer to method `_get_to_invoice_qty()` for more information on how this quantity is calculated. - upselling: this is possible only for a product invoiced on ordered quantities for which we delivered more than expected. The could arise if, for example, a project took more time than expected but we decided not to invoice the extra cost to the client. This occurs onyl in state 'sale', so that when a SO is set to done, the upselling opportunity is removed from the list. - invoiced: the quantity invoiced is larger or equal to the quantity ordered. """ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') for line in self: if line.state not in ('sale', 'done'): line.invoice_status = 'no' elif not float_is_zero(line.qty_to_invoice, precision_digits=precision): line.invoice_status = 'to invoice' elif line.state == 'sale' and line.product_id.invoice_policy == 'order' and\ float_compare(line.qty_delivered, line.product_uom_qty, precision_digits=precision) == 1: line.invoice_status = 'upselling' elif float_compare(line.qty_invoiced, line.product_uom_qty, precision_digits=precision) >= 0: line.invoice_status = 'invoiced' else: line.invoice_status = 'no' @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') def _compute_amount(self): """ Compute the amounts of the SO line. """ for line in self: price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) taxes = line.tax_id.compute_all(price, line.order_id.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) line.update({ 'price_tax': sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])), 'price_total': taxes['total_included'], 'price_subtotal': taxes['total_excluded'], }) @api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered') def _compute_product_updatable(self): for line in self: if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)): line.product_updatable = False else: line.product_updatable = True @api.depends('product_id.invoice_policy', 'order_id.state') def _compute_qty_delivered_updateable(self): for line in self: line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.service_type == 'manual') and (line.product_id.expense_policy == 'no') @api.depends('state', 'price_reduce_taxinc', 'qty_delivered', 'invoice_lines', 'invoice_lines.price_total', 'invoice_lines.invoice_id', 'invoice_lines.invoice_id.state', 'invoice_lines.invoice_id.refund_invoice_ids', 'invoice_lines.invoice_id.refund_invoice_ids.state', 'invoice_lines.invoice_id.refund_invoice_ids.amount_total') def _compute_invoice_amount(self): for line in self: # Invoice lines referenced by this line invoice_lines = line.invoice_lines.filtered(lambda l: l.invoice_id.state in ('open', 'paid')) # Refund invoices linked to invoice_lines refund_invoices = invoice_lines.mapped('invoice_id.refund_invoice_ids').filtered(lambda inv: inv.state in ('open', 'paid')) # Total invoiced amount invoiced_amount_total = sum(invoice_lines.mapped('price_total')) # Total refunded amount refund_amount_total = sum(refund_invoices.mapped('amount_total')) # Total of remaining amount to invoice on the sale ordered (and draft invoice included) to support upsell (when # delivered quantity is higher than ordered one). Draft invoice are ignored on purpose, the 'to invoice' should # come only from the SO lines. total_sale_line = line.price_total if line.product_id.invoice_policy == 'delivery': total_sale_line = line.price_reduce_taxinc * line.qty_delivered line.amt_invoiced = invoiced_amount_total - refund_amount_total line.amt_to_invoice = (total_sale_line - invoiced_amount_total) if line.state in ['sale', 'done'] else 0.0 @api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'order_id.state') def _get_to_invoice_qty(self): """ Compute the quantity to invoice. If the invoice policy is order, the quantity to invoice is calculated from the ordered quantity. Otherwise, the quantity delivered is used. """ for line in self: if line.order_id.state in ['sale', 'done']: if line.product_id.invoice_policy == 'order': line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced else: line.qty_to_invoice = line.qty_delivered - line.qty_invoiced else: line.qty_to_invoice = 0 @api.depends('invoice_lines.invoice_id.state', 'invoice_lines.quantity') def _get_invoice_qty(self): """ Compute the quantity invoiced. If case of a refund, the quantity invoiced is decreased. Note that this is the case only if the refund is generated from the SO and that is intentional: if a refund made would automatically decrease the invoiced quantity, then there is a risk of reinvoicing it automatically, which may not be wanted at all. That's why the refund has to be created from the SO """ for line in self: qty_invoiced = 0.0 for invoice_line in line.invoice_lines: if invoice_line.invoice_id.state != 'cancel': if invoice_line.invoice_id.type == 'out_invoice': qty_invoiced += invoice_line.uom_id._compute_quantity(invoice_line.quantity, line.product_uom) elif invoice_line.invoice_id.type == 'out_refund': qty_invoiced -= invoice_line.uom_id._compute_quantity(invoice_line.quantity, line.product_uom) line.qty_invoiced = qty_invoiced @api.depends('price_unit', 'discount') def _get_price_reduce(self): for line in self: line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0) @api.depends('price_total', 'product_uom_qty') def _get_price_reduce_tax(self): for line in self: line.price_reduce_taxinc = line.price_total / line.product_uom_qty if line.product_uom_qty else 0.0 @api.depends('price_subtotal', 'product_uom_qty') def _get_price_reduce_notax(self): for line in self: line.price_reduce_taxexcl = line.price_subtotal / line.product_uom_qty if line.product_uom_qty else 0.0 @api.multi def _compute_tax_id(self): for line in self: fpos = line.order_id.fiscal_position_id or line.order_id.partner_id.property_account_position_id # If company_id is set, always filter taxes by the company taxes = line.product_id.taxes_id.filtered(lambda r: not line.company_id or r.company_id == line.company_id) line.tax_id = fpos.map_tax(taxes, line.product_id, line.order_id.partner_shipping_id) if fpos else taxes @api.model def _get_purchase_price(self, pricelist, product, product_uom, date): return {} @api.model def _prepare_add_missing_fields(self, values): """ Deduce missing required fields from the onchange """ res = {} onchange_fields = ['name', 'price_unit', 'product_uom', 'tax_id'] if values.get('order_id') and values.get('product_id') and any(f not in values for f in onchange_fields): line = self.new(values) line.product_id_change() for field in onchange_fields: if field not in values: res[field] = line._fields[field].convert_to_write(line[field], line) return res @api.model def create(self, values): values.update(self._prepare_add_missing_fields(values)) line = super(SaleOrderLine, self).create(values) if line.order_id.state == 'sale': msg = _("Extra line with %s ") % (line.product_id.display_name,) line.order_id.message_post(body=msg) # create an analytic account if at least an expense product if line.product_id.expense_policy != 'no' and not self.order_id.analytic_account_id: self.order_id._create_analytic_account() return line def _update_line_quantity(self, values): orders = self.mapped('order_id') for order in orders: order_lines = self.filtered(lambda x: x.order_id == order) msg = "The ordered quantity has been updated." order.message_post(body=msg) @api.multi def write(self, values): if 'product_uom_qty' in values: precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') self.filtered( lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0)._update_line_quantity(values) # Prevent writing on a locked SO. protected_fields = self._get_protected_fields() if 'done' in self.mapped('order_id.state') and any(f in values.keys() for f in protected_fields): fields = self.env['ir.model.fields'].search([ ('name', 'in', protected_fields), ('model', '=', self._name) ]) raise UserError( _('It is forbidden to modify the following fields in a locked order:\n%s') % '\n'.join(fields.mapped('field_description')) ) result = super(SaleOrderLine, self).write(values) return result order_id = fields.Many2one('sale.order', string='Order Reference', required=True, ondelete='cascade', index=True, copy=False) name = fields.Text(string='Description', required=True) sequence = fields.Integer(string='Sequence', default=10) invoice_lines = fields.Many2many('account.invoice.line', 'sale_order_line_invoice_rel', 'order_line_id', 'invoice_line_id', string='Invoice Lines', copy=False) invoice_status = fields.Selection([ ('upselling', 'Upselling Opportunity'), ('invoiced', 'Fully Invoiced'), ('to invoice', 'To Invoice'), ('no', 'Nothing to Invoice') ], string='Invoice Status', compute='_compute_invoice_status', store=True, readonly=True, default='no') price_unit = fields.Float('Unit Price', required=True, digits=dp.get_precision('Product Price'), default=0.0) price_subtotal = fields.Monetary(compute='_compute_amount', string='Subtotal', readonly=True, store=True) price_tax = fields.Float(compute='_compute_amount', string='Taxes', readonly=True, store=True) price_total = fields.Monetary(compute='_compute_amount', string='Total', readonly=True, store=True) price_reduce = fields.Float(compute='_get_price_reduce', string='Price Reduce', digits=dp.get_precision('Product Price'), readonly=True, store=True) tax_id = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) price_reduce_taxinc = fields.Monetary(compute='_get_price_reduce_tax', string='Price Reduce Tax inc', readonly=True, store=True) price_reduce_taxexcl = fields.Monetary(compute='_get_price_reduce_notax', string='Price Reduce Tax excl', readonly=True, store=True) discount = fields.Float(string='Discount (%)', digits=dp.get_precision('Discount'), default=0.0) product_id = fields.Many2one('product.product', string='Product', domain=[('sale_ok', '=', True)], change_default=True, ondelete='restrict', required=True) product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True) product_uom_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0) product_uom = fields.Many2one('product.uom', string='Unit of Measure', required=True) # Non-stored related field to allow portal user to see the image of the product he has ordered product_image = fields.Binary('Product Image', related="product_id.image", store=False) qty_delivered_updateable = fields.Boolean(compute='_compute_qty_delivered_updateable', string='Can Edit Delivered', readonly=True, default=True) qty_delivered = fields.Float(string='Delivered', copy=False, digits=dp.get_precision('Product Unit of Measure'), default=0.0) qty_to_invoice = fields.Float( compute='_get_to_invoice_qty', string='To Invoice', store=True, readonly=True, digits=dp.get_precision('Product Unit of Measure')) qty_invoiced = fields.Float( compute='_get_invoice_qty', string='Invoiced', store=True, readonly=True, digits=dp.get_precision('Product Unit of Measure')) salesman_id = fields.Many2one(related='order_id.user_id', store=True, string='Salesperson', readonly=True) currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True) company_id = fields.Many2one(related='order_id.company_id', string='Company', store=True, readonly=True) branch_id = fields.Many2one(related='order_id.branch_id', string='Branch', store=True, readonly=True) order_partner_id = fields.Many2one(related='order_id.partner_id', store=True, string='Customer') analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags') is_downpayment = fields.Boolean( string="Is a down payment", help="Down payments are made when creating invoices from a sales order." " They are not copied when duplicating a sales order.") state = fields.Selection([ ('draft', 'Quotation'), ('sent', 'Quotation Sent'), ('sale', 'Sales Order'), ('done', 'Done'), ('cancel', 'Cancelled'), ], related='order_id.state', string='Order Status', readonly=True, copy=False, store=True, default='draft') customer_lead = fields.Float( 'Delivery Lead Time', required=True, default=0.0, help="Number of days between the order confirmation and the shipping of the products to the customer", oldname="delay") amt_to_invoice = fields.Monetary(string='Amount To Invoice', compute='_compute_invoice_amount', compute_sudo=True, store=True) amt_invoiced = fields.Monetary(string='Amount Invoiced', compute='_compute_invoice_amount', compute_sudo=True, store=True) layout_category_id = fields.Many2one('sale.layout_category', string='Section') layout_category_sequence = fields.Integer(string='Layout Sequence') # TODO: remove layout_category_sequence in master or make it work properly @api.multi def _prepare_invoice_line(self, qty): """ Prepare the dict of values to create the new invoice line for a sales order line. :param qty: float quantity to invoice """ self.ensure_one() res = {} account = self.product_id.property_account_income_id or self.product_id.categ_id.property_account_income_categ_id if not account: raise UserError(_('Please define income account for this product: "%s" (id:%d) - or for its category: "%s".') % (self.product_id.name, self.product_id.id, self.product_id.categ_id.name)) fpos = self.order_id.fiscal_position_id or self.order_id.partner_id.property_account_position_id if fpos: account = fpos.map_account(account) res = { 'name': self.name, 'sequence': self.sequence, 'origin': self.order_id.name, 'branch_id': self.branch_id and self.branch_id.id, 'account_id': account.id, 'price_unit': self.price_unit, 'quantity': qty, 'discount': self.discount, 'uom_id': self.product_uom.id, 'product_id': self.product_id.id or False, 'layout_category_id': self.layout_category_id and self.layout_category_id.id or False, 'invoice_line_tax_ids': [(6, 0, self.tax_id.ids)], 'account_analytic_id': self.order_id.analytic_account_id.id, 'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)], } return res @api.multi def invoice_line_create(self, invoice_id, qty): """ Create an invoice line. The quantity to invoice can be positive (invoice) or negative (refund). :param invoice_id: integer :param qty: float quantity to invoice :returns recordset of account.invoice.line created """ invoice_lines = self.env['account.invoice.line'] precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') for line in self: if not float_is_zero(qty, precision_digits=precision): vals = line._prepare_invoice_line(qty=qty) vals.update({'invoice_id': invoice_id, 'sale_line_ids': [(6, 0, [line.id])]}) invoice_lines |= self.env['account.invoice.line'].create(vals) return invoice_lines @api.multi def _prepare_procurement_values(self, group_id=False): """ Prepare specific key for moves or other components that will be created from a procurement rule comming from a sale order line. This method could be override in order to add other custom key that could be used in move/po creation. """ return {} @api.multi def _get_display_price(self, product): # TO DO: move me in master/saas-16 on sale.order if self.order_id.pricelist_id.discount_policy == 'with_discount': return product.with_context(pricelist=self.order_id.pricelist_id.id).price final_price, rule_id = self.order_id.pricelist_id.get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) context_partner = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order) base_price, currency_id = self.with_context(context_partner)._get_real_price_currency(self.product_id, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) if currency_id != self.order_id.pricelist_id.currency_id.id: base_price = self.env['res.currency'].browse(currency_id).with_context(context_partner).compute(base_price, self.order_id.pricelist_id.currency_id) # negative discounts (= surcharge) are included in the display price return max(base_price, final_price) @api.multi @api.onchange('product_id') def product_id_change(self): if not self.product_id: return {'domain': {'product_uom': []}} vals = {} domain = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]} if not self.product_uom or (self.product_id.uom_id.id != self.product_uom.id): vals['product_uom'] = self.product_id.uom_id vals['product_uom_qty'] = 1.0 product = self.product_id.with_context( lang=self.order_id.partner_id.lang, partner=self.order_id.partner_id.id, quantity=vals.get('product_uom_qty') or self.product_uom_qty, date=self.order_id.date_order, pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id ) result = {'domain': domain} title = False message = False warning = {} if product.sale_line_warn != 'no-message': title = _("Warning for %s") % product.name message = product.sale_line_warn_msg warning['title'] = title warning['message'] = message result = {'warning': warning} if product.sale_line_warn == 'block': self.product_id = False return result name = product.name_get()[0][1] if product.description_sale: name += '\n' + product.description_sale vals['name'] = name self._compute_tax_id() if self.order_id.pricelist_id and self.order_id.partner_id: vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) self.update(vals) return result @api.onchange('product_uom', 'product_uom_qty') def product_uom_change(self): if not self.product_uom or not self.product_id: self.price_unit = 0.0 return if self.order_id.pricelist_id and self.order_id.partner_id: product = self.product_id.with_context( lang=self.order_id.partner_id.lang, partner=self.order_id.partner_id.id, quantity=self.product_uom_qty, date=self.order_id.date_order, pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id, fiscal_position=self.env.context.get('fiscal_position') ) self.price_unit = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id) @api.multi def name_get(self): if self._context.get('sale_show_order_product_name'): result = [] for so_line in self: name = '%s - %s' % (so_line.order_id.name, so_line.product_id.name) result.append((so_line.id, name)) return result return super(SaleOrderLine, self).name_get() @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): if self._context.get('sale_show_order_product_name'): if operator in ('ilike', 'like', '=', '=like', '=ilike'): domain = expression.AND([ args or [], ['|', ('order_id.name', operator, name), ('name', operator, name)] ]) return self.search(domain, limit=limit).name_get() return super(SaleOrderLine, self).name_search(name, args, operator, limit) @api.multi def unlink(self): if self.filtered(lambda x: x.state in ('sale', 'done')): raise UserError(_('You can not remove a sales order line.\nDiscard changes and try setting the quantity to 0.')) return super(SaleOrderLine, self).unlink() @api.multi def _get_delivered_qty(self): ''' Intended to be overridden in sale_stock and sale_mrp :return: the quantity delivered :rtype: float ''' return 0.0 def _get_real_price_currency(self, product, rule_id, qty, uom, pricelist_id): """Retrieve the price before applying the pricelist :param obj product: object of current product record :parem float qty: total quentity of product :param tuple price_and_rule: tuple(price, suitable_rule) coming from pricelist computation :param obj uom: unit of measure of current order line :param integer pricelist_id: pricelist id of sales order""" PricelistItem = self.env['product.pricelist.item'] field_name = 'lst_price' currency_id = None product_currency = None if rule_id: pricelist_item = PricelistItem.browse(rule_id) if pricelist_item.pricelist_id.discount_policy == 'without_discount': while pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id and pricelist_item.base_pricelist_id.discount_policy == 'without_discount': price, rule_id = pricelist_item.base_pricelist_id.with_context(uom=uom.id).get_product_price_rule(product, qty, self.order_id.partner_id) pricelist_item = PricelistItem.browse(rule_id) if pricelist_item.base == 'standard_price': field_name = 'standard_price' if pricelist_item.base == 'pricelist' and pricelist_item.base_pricelist_id: field_name = 'price' product = product.with_context(pricelist=pricelist_item.base_pricelist_id.id) product_currency = pricelist_item.base_pricelist_id.currency_id currency_id = pricelist_item.pricelist_id.currency_id product_currency = product_currency or(product.company_id and product.company_id.currency_id) or self.env.user.company_id.currency_id if not currency_id: currency_id = product_currency cur_factor = 1.0 else: if currency_id.id == product_currency.id: cur_factor = 1.0 else: cur_factor = currency_id._get_conversion_rate(product_currency, currency_id) product_uom = self.env.context.get('uom') or product.uom_id.id if uom and uom.id != product_uom: # the unit price is in a different uom uom_factor = uom._compute_price(1.0, product.uom_id) else: uom_factor = 1.0 return product[field_name] * uom_factor * cur_factor, currency_id.id def _get_protected_fields(self): return [ 'product_id', 'name', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id', 'analytic_tag_ids' ] @api.onchange('product_id', 'price_unit', 'product_uom', 'product_uom_qty', 'tax_id') def _onchange_discount(self): self.discount = 0.0 if not (self.product_id and self.product_uom and self.order_id.partner_id and self.order_id.pricelist_id and self.order_id.pricelist_id.discount_policy == 'without_discount' and self.env.user.has_group('sale.group_discount_per_so_line')): return context_partner = dict(self.env.context, partner_id=self.order_id.partner_id.id, date=self.order_id.date_order) pricelist_context = dict(context_partner, uom=self.product_uom.id) price, rule_id = self.order_id.pricelist_id.with_context(pricelist_context).get_product_price_rule(self.product_id, self.product_uom_qty or 1.0, self.order_id.partner_id) new_list_price, currency_id = self.with_context(context_partner)._get_real_price_currency(self.product_id, rule_id, self.product_uom_qty, self.product_uom, self.order_id.pricelist_id.id) if new_list_price != 0: if self.order_id.pricelist_id.currency_id.id != currency_id: # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency new_list_price = self.env['res.currency'].browse(currency_id).with_context(context_partner).compute(new_list_price, self.order_id.pricelist_id.currency_id) discount = (new_list_price - price) / new_list_price * 100 if discount > 0: self.discount = discount ########################### # Analytic Methods ########################### @api.multi def _analytic_compute_delivered_quantity_domain(self): """ Return the domain of the analytic lines to use to recompute the delivered quantity on SO lines. This method is a hook: since analytic line are used for timesheet, expense, ... each use case should provide its part of the domain. """ return [('so_line', 'in', self.ids), ('amount', '<=', 0.0)] @api.multi def _analytic_compute_delivered_quantity(self): """ Compute and write the delivered quantity of current SO lines, based on their related analytic lines. """ # avoid recomputation if no SO lines concerned if not self: return False # group anaytic lines by product uom and so line domain = self._analytic_compute_delivered_quantity_domain() data = self.env['account.analytic.line'].read_group( domain, ['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False ) # Force recompute for the "unlink last line" case: if remove the last AAL link to the SO, the read_group # will give no value for the qty of the SOL, so we need to reset it to 0.0 value_to_write = {} if self._context.get('sale_analytic_force_recompute'): value_to_write = dict.fromkeys([sol for sol in self], 0.0) # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines for item in data: if not item['product_uom_id']: continue so_line = self.browse(item['so_line'][0]) value_to_write.setdefault(so_line, 0.0) uom = self.env['product.uom'].browse(item['product_uom_id'][0]) if so_line.product_uom.category_id == uom.category_id: qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom) else: qty = item['unit_amount'] value_to_write[so_line] += qty # write the delivered quantity for so_line, qty in value_to_write.items(): so_line.write({'qty_delivered': qty}) return True