# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from itertools import groupby from datetime import datetime, timedelta from odoo import api, fields, models, _ from odoo.exceptions import UserError from odoo.tools import float_is_zero, float_compare, DEFAULT_SERVER_DATETIME_FORMAT from odoo.tools.misc import formatLang import odoo.addons.decimal_precision as dp class SaleOrder(models.Model): _name = "sale.order" _inherit = ['mail.thread', 'ir.needaction_mixin'] _description = "Sales Order" _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 # FORWARDPORT UP TO 10.0 if order.company_id.tax_calculation_rounding_method == 'round_globally': 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=order.partner_shipping_id) amount_tax += sum(t.get('amount', 0.0) for t in taxes.get('taxes', [])) else: 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), ('company_id', '=', order.company_id.id)]).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 _default_note(self): return self.env.user.company_id.sale_note @api.model def _get_default_team(self): return self.env['crm.team']._get_default_team_id() @api.onchange('fiscal_position_id') def _compute_tax_id(self): """ Trigger the recompute of the taxes if the fiscal position is changed on the SO. """ for order in self: order.order_line._compute_tax_id() def _inverse_project_id(self): self.project_id = self.related_project_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) 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.") 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 sale order is confirmed.", oldname="date_confirm", copy=False) 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) project_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) related_project_id = fields.Many2one('account.analytic.account', inverse='_inverse_project_id', related='project_id', string='Analytic Account', help="The analytic account related to a sales order.") order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True) 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='always') amount_tax = fields.Monetary(string='Taxes', store=True, readonly=True, compute='_amount_all', track_visibility='always') 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 Team', change_default=True, default=_get_default_team, oldname='section_id') procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False) product_id = fields.Many2one('product.product', related='order_line.product_id', string='Product') @api.model def _get_customer_lead(self, product_tmpl_id): return False @api.multi def button_dummy(self): return True @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 term - 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'], } if 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.user_id: values['user_id'] = self.partner_id.user_id.id 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 _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 sale journal for this company.')) invoice_vals = { 'name': self.client_order_ref or '', 'origin': self.name, '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['report'].get_action(self, 'sale.report_saleorder') @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 = {} invoices_origin = {} invoices_name = {} 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 invoices_origin[group_key] = [invoice.origin] invoices_name[group_key] = [invoice.name] elif group_key in invoices: if order.name not in invoices_origin[group_key]: invoices_origin[group_key].append(order.name) if order.client_order_ref and order.client_order_ref not in invoices_name[group_key]: invoices_name[group_key].append(order.client_order_ref) 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 for group_key in invoices: invoices[group_key].write({'name': ', '.join(invoices_name[group_key]), 'origin': ', '.join(invoices_origin[group_key])}) if not invoices: raise UserError(_('There is no invoicable line.')) for invoice in invoices.values(): if not invoice.invoice_line_ids: raise UserError(_('There is no invoicable 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']) orders.write({ 'state': 'draft', 'procurement_group_id': False, }) return orders.mapped('order_line').mapped('procurement_ids').write({'sale_line_id': False}) @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 = dict() ctx.update({ '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" }) 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'}) def _prepare_procurement_group(self): return {'name': self.name} @api.multi def action_confirm(self): for order in self: order.state = 'sale' order.confirmation_date = fields.Datetime.now() if self.env.context.get('send_email'): self.force_quotation_send() order.order_line._action_procurement_create() if self.env['ir.values'].get_default('sale.config.settings', '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, 'company_id': order.company_id.id, 'partner_id': order.partner_id.id }) order.project_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 = {} currency = self.currency_id or self.company_id.currency_id for line in self.order_line: price_reduce = line.price_unit * (1.0 - line.discount / 100.0) taxes = line.tax_id.compute_all(price_reduce, quantity=line.product_uom_qty, product=line.product_id, partner=self.partner_shipping_id)['taxes'] for tax in line.tax_id: group = tax.tax_group_id res.setdefault(group, 0.0) for t in taxes: if t['id'] == tax.id or t['id'] in tax.children_tax_ids.ids: res[group] += t['amount'] res = sorted(res.items(), key=lambda l: l[0].sequence) res = map(lambda l: (l[0].name, l[1]), res) return res 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': taxes['total_included'] - taxes['total_excluded'], 'price_total': taxes['total_included'], 'price_subtotal': taxes['total_excluded'], }) @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.track_service == 'manual') and (line.product_id.expense_policy == 'no') @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.multi def _prepare_order_line_procurement(self, group_id=False): self.ensure_one() return { 'name': self.name, 'origin': self.order_id.name, 'date_planned': datetime.strptime(self.order_id.date_order, DEFAULT_SERVER_DATETIME_FORMAT) + timedelta(days=self.customer_lead), 'product_id': self.product_id.id, 'product_qty': self.product_uom_qty, 'product_uom': self.product_uom.id, 'company_id': self.order_id.company_id.id, 'group_id': group_id, 'sale_line_id': self.id } @api.multi def _action_procurement_create(self): """ Create procurements based on quantity ordered. If the quantity is increased, new procurements are created. If the quantity is decreased, no automated action is taken. """ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') new_procs = self.env['procurement.order'] # Empty recordset for line in self: if line.state != 'sale' or not line.product_id._need_procurement(): continue qty = 0.0 for proc in line.procurement_ids.filtered(lambda r: r.state != 'cancel'): qty += proc.product_qty if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: continue if not line.order_id.procurement_group_id: vals = line.order_id._prepare_procurement_group() line.order_id.procurement_group_id = self.env["procurement.group"].create(vals) vals = line._prepare_order_line_procurement(group_id=line.order_id.procurement_group_id.id) vals['product_qty'] = line.product_uom_qty - qty new_proc = self.env["procurement.order"].with_context(procurement_autorun_defer=True).create(vals) new_proc.message_post_with_view('mail.message_origin_link', values={'self': new_proc, 'origin': line.order_id}, subtype_id=self.env.ref('mail.mt_note').id) new_procs += new_proc new_procs.run() return new_procs @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': line._action_procurement_create() msg = _("Extra line with %s ") % (line.product_id.display_name,) line.order_id.message_post(body=msg) return line @api.multi def write(self, values): lines = False changed_lines = False if 'product_uom_qty' in values: precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') lines = self.filtered( lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) changed_lines = self.filtered( lambda r: r.state == 'sale' and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) != 0) if changed_lines: orders = self.mapped('order_id') for order in orders: order_lines = changed_lines.filtered(lambda x: x.order_id == order) msg = "" if any([values['product_uom_qty'] < x.product_uom_qty for x in order_lines]): msg += "" + _('The ordered quantity has been decreased. Do not forget to take it into account on your invoices and delivery orders.') + '' msg += "" order.message_post(body=msg) # 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) if lines: lines._action_procurement_create() 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.Monetary(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.Monetary(compute='_get_price_reduce', string='Price Reduce', 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_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) 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) 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') state = fields.Selection([ ('draft', 'Quotation'), ('sent', 'Quotation Sent'), ('sale', 'Sale 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") procurement_ids = fields.One2many('procurement.order', 'sale_line_id', string='Procurements') 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, '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.project_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 """ 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])]}) self.env['account.invoice.line'].create(vals) @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 unlink(self): if self.filtered(lambda x: x.state in ('sale', 'done')): raise UserError(_('You can not remove a sale 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 sale 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