1232 lines
60 KiB
Python
1232 lines
60 KiB
Python
# -*- 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.addons import decimal_precision as dp
|
|
|
|
|
|
class SaleOrder(models.Model):
|
|
_name = "sale.order"
|
|
_inherit = ['mail.thread', 'mail.activity.mixin', 'portal.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 '<p class=''oe_view_nocontent_create''">%s</p>' % (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')
|
|
|
|
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,
|
|
'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,
|
|
'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 = "<b>The ordered quantity has been updated.</b><ul>"
|
|
for line in order_lines:
|
|
msg += "<li> %s:" % (line.product_id.display_name,)
|
|
msg += "<br/>" + _("Ordered Quantity") + ": %s -> %s <br/>" % (
|
|
line.product_uom_qty, float(values['product_uom_qty']),)
|
|
if line.product_id.type in ('consu', 'product'):
|
|
msg += _("Delivered Quantity") + ": %s <br/>" % (line.qty_delivered,)
|
|
msg += _("Invoiced Quantity") + ": %s <br/>" % (line.qty_invoiced,)
|
|
msg += "</ul>"
|
|
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)
|
|
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,
|
|
'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
|