2018-01-16 06:58:15 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
2018-01-16 11:34:37 +01:00
|
|
|
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
|
2018-01-16 06:58:15 +01:00
|
|
|
import logging
|
|
|
|
import random
|
|
|
|
from datetime import datetime
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
|
2018-01-16 11:34:37 +01:00
|
|
|
from flectra import api, models, fields, _
|
|
|
|
from flectra.http import request
|
|
|
|
from flectra.osv import expression
|
|
|
|
from flectra.exceptions import UserError, ValidationError
|
2018-01-16 06:58:15 +01:00
|
|
|
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
class SaleOrder(models.Model):
|
|
|
|
_inherit = "sale.order"
|
|
|
|
|
|
|
|
website_order_line = fields.One2many(
|
|
|
|
'sale.order.line', 'order_id',
|
|
|
|
string='Order Lines displayed on Website', readonly=True,
|
|
|
|
help='Order Lines to be displayed on the website. They should not be used for computation purpose.',
|
|
|
|
)
|
2017-12-22 15:17:53 +01:00
|
|
|
website_id = fields.Many2one('website', string='Website',
|
|
|
|
help='Website reference for quotation/order.')
|
2018-01-16 06:58:15 +01:00
|
|
|
cart_quantity = fields.Integer(compute='_compute_cart_info', string='Cart Quantity')
|
|
|
|
only_services = fields.Boolean(compute='_compute_cart_info', string='Only Services')
|
|
|
|
can_directly_mark_as_paid = fields.Boolean(compute='_compute_can_directly_mark_as_paid',
|
|
|
|
string="Can be directly marked as paid", store=True,
|
|
|
|
help="""Checked if the sales order can directly be marked as paid, i.e. if the quotation
|
|
|
|
is sent or confirmed and if the payment acquire is of the type transfer or manual""")
|
|
|
|
is_abandoned_cart = fields.Boolean('Abandoned Cart', compute='_compute_abandoned_cart', search='_search_abandoned_cart')
|
|
|
|
cart_recovery_email_sent = fields.Boolean('Cart recovery email already sent')
|
|
|
|
|
|
|
|
@api.depends('state', 'payment_tx_id', 'payment_tx_id.state',
|
|
|
|
'payment_acquirer_id', 'payment_acquirer_id.provider')
|
|
|
|
def _compute_can_directly_mark_as_paid(self):
|
|
|
|
for order in self:
|
|
|
|
order.can_directly_mark_as_paid = order.state in ['sent', 'sale'] and order.payment_tx_id and order.payment_acquirer_id.provider in ['transfer', 'manual']
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
@api.depends('website_order_line.product_uom_qty', 'website_order_line.product_id')
|
|
|
|
def _compute_cart_info(self):
|
|
|
|
for order in self:
|
|
|
|
order.cart_quantity = int(sum(order.mapped('website_order_line.product_uom_qty')))
|
|
|
|
order.only_services = all(l.product_id.type in ('service', 'digital') for l in order.website_order_line)
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
@api.depends('team_id.team_type', 'date_order', 'order_line', 'state', 'partner_id')
|
|
|
|
def _compute_abandoned_cart(self):
|
|
|
|
abandoned_delay = float(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_abandoned_delay', '1.0'))
|
|
|
|
abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay))
|
|
|
|
for order in self:
|
|
|
|
domain = order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line
|
|
|
|
order.is_abandoned_cart = bool(domain)
|
|
|
|
|
|
|
|
def _search_abandoned_cart(self, operator, value):
|
|
|
|
abandoned_delay = float(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_abandoned_delay', '1.0'))
|
|
|
|
abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay))
|
|
|
|
abandoned_domain = expression.normalize_domain([
|
|
|
|
('date_order', '<=', abandoned_datetime),
|
|
|
|
('team_id.team_type', '=', 'website'),
|
|
|
|
('state', '=', 'draft'),
|
|
|
|
('partner_id.id', '!=', self.env.ref('base.public_partner').id),
|
|
|
|
('order_line', '!=', False)
|
|
|
|
])
|
|
|
|
# is_abandoned domain possibilities
|
|
|
|
if (operator not in expression.NEGATIVE_TERM_OPERATORS and value) or (operator in expression.NEGATIVE_TERM_OPERATORS and not value):
|
|
|
|
return abandoned_domain
|
|
|
|
return expression.distribute_not(abandoned_domain) # negative domain
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs):
|
|
|
|
self.ensure_one()
|
|
|
|
product = self.env['product.product'].browse(product_id)
|
|
|
|
|
|
|
|
# split lines with the same product if it has untracked attributes
|
|
|
|
if product and product.mapped('attribute_line_ids').filtered(lambda r: not r.attribute_id.create_variant) and not line_id:
|
|
|
|
return self.env['sale.order.line']
|
|
|
|
|
|
|
|
domain = [('order_id', '=', self.id), ('product_id', '=', product_id)]
|
|
|
|
if line_id:
|
|
|
|
domain += [('id', '=', line_id)]
|
|
|
|
return self.env['sale.order.line'].sudo().search(domain)
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def _website_product_id_change(self, order_id, product_id, qty=0):
|
|
|
|
order = self.sudo().browse(order_id)
|
|
|
|
product_context = dict(self.env.context)
|
|
|
|
product_context.setdefault('lang', order.partner_id.lang)
|
|
|
|
product_context.update({
|
|
|
|
'partner': order.partner_id.id,
|
|
|
|
'quantity': qty,
|
|
|
|
'date': order.date_order,
|
|
|
|
'pricelist': order.pricelist_id.id,
|
|
|
|
})
|
|
|
|
product = self.env['product.product'].with_context(product_context).browse(product_id)
|
|
|
|
pu = product.price
|
|
|
|
if order.pricelist_id and order.partner_id:
|
|
|
|
order_line = order._cart_find_product_line(product.id)
|
|
|
|
if order_line:
|
|
|
|
pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
|
|
|
|
|
|
|
|
return {
|
|
|
|
'product_id': product_id,
|
|
|
|
'product_uom_qty': qty,
|
|
|
|
'order_id': order_id,
|
|
|
|
'product_uom': product.uom_id.id,
|
|
|
|
'price_unit': pu,
|
|
|
|
}
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def _get_line_description(self, order_id, product_id, attributes=None):
|
|
|
|
if not attributes:
|
|
|
|
attributes = {}
|
|
|
|
|
|
|
|
order = self.sudo().browse(order_id)
|
|
|
|
product_context = dict(self.env.context)
|
|
|
|
product_context.setdefault('lang', order.partner_id.lang)
|
|
|
|
product = self.env['product.product'].with_context(product_context).browse(product_id)
|
|
|
|
|
|
|
|
name = product.display_name
|
|
|
|
|
|
|
|
# add untracked attributes in the name
|
|
|
|
untracked_attributes = []
|
|
|
|
for k, v in attributes.items():
|
|
|
|
# attribute should be like 'attribute-48-1' where 48 is the product_id, 1 is the attribute_id and v is the attribute value
|
|
|
|
attribute_value = self.env['product.attribute.value'].sudo().browse(int(v))
|
|
|
|
if attribute_value and not attribute_value.attribute_id.create_variant:
|
|
|
|
untracked_attributes.append(attribute_value.name)
|
|
|
|
if untracked_attributes:
|
|
|
|
name += '\n%s' % (', '.join(untracked_attributes))
|
|
|
|
|
|
|
|
if product.description_sale:
|
|
|
|
name += '\n%s' % (product.description_sale)
|
|
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, attributes=None, **kwargs):
|
|
|
|
""" Add or set product quantity, add_qty can be negative """
|
|
|
|
self.ensure_one()
|
|
|
|
SaleOrderLineSudo = self.env['sale.order.line'].sudo()
|
|
|
|
|
|
|
|
try:
|
|
|
|
if add_qty:
|
|
|
|
add_qty = float(add_qty)
|
|
|
|
except ValueError:
|
|
|
|
add_qty = 1
|
|
|
|
try:
|
|
|
|
if set_qty:
|
|
|
|
set_qty = float(set_qty)
|
|
|
|
except ValueError:
|
|
|
|
set_qty = 0
|
|
|
|
quantity = 0
|
|
|
|
order_line = False
|
|
|
|
if self.state != 'draft':
|
|
|
|
request.session['sale_order_id'] = None
|
|
|
|
raise UserError(_('It is forbidden to modify a sales order which is not in draft status'))
|
|
|
|
if line_id is not False:
|
|
|
|
order_lines = self._cart_find_product_line(product_id, line_id, **kwargs)
|
|
|
|
order_line = order_lines and order_lines[0]
|
|
|
|
|
|
|
|
# Create line if no line with product_id can be located
|
|
|
|
if not order_line:
|
|
|
|
values = self._website_product_id_change(self.id, product_id, qty=1)
|
|
|
|
values['name'] = self._get_line_description(self.id, product_id, attributes=attributes)
|
|
|
|
order_line = SaleOrderLineSudo.create(values)
|
|
|
|
|
|
|
|
try:
|
|
|
|
order_line._compute_tax_id()
|
|
|
|
except ValidationError as e:
|
|
|
|
# The validation may occur in backend (eg: taxcloud) but should fail silently in frontend
|
|
|
|
_logger.debug("ValidationError occurs during tax compute. %s" % (e))
|
|
|
|
if add_qty:
|
|
|
|
add_qty -= 1
|
|
|
|
|
|
|
|
# compute new quantity
|
|
|
|
if set_qty:
|
|
|
|
quantity = set_qty
|
|
|
|
elif add_qty is not None:
|
|
|
|
quantity = order_line.product_uom_qty + (add_qty or 0)
|
|
|
|
|
|
|
|
# Remove zero of negative lines
|
|
|
|
if quantity <= 0:
|
|
|
|
order_line.unlink()
|
|
|
|
else:
|
|
|
|
# update line
|
|
|
|
values = self._website_product_id_change(self.id, product_id, qty=quantity)
|
|
|
|
if self.pricelist_id.discount_policy == 'with_discount' and not self.env.context.get('fixed_price'):
|
|
|
|
order = self.sudo().browse(self.id)
|
|
|
|
product_context = dict(self.env.context)
|
|
|
|
product_context.setdefault('lang', order.partner_id.lang)
|
|
|
|
product_context.update({
|
|
|
|
'partner': order.partner_id.id,
|
|
|
|
'quantity': quantity,
|
|
|
|
'date': order.date_order,
|
|
|
|
'pricelist': order.pricelist_id.id,
|
|
|
|
})
|
|
|
|
product = self.env['product.product'].with_context(product_context).browse(product_id)
|
|
|
|
values['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(
|
|
|
|
order_line._get_display_price(product),
|
|
|
|
order_line.product_id.taxes_id,
|
|
|
|
order_line.tax_id,
|
|
|
|
self.company_id
|
|
|
|
)
|
|
|
|
|
|
|
|
order_line.write(values)
|
|
|
|
|
|
|
|
return {'line_id': order_line.id, 'quantity': quantity}
|
|
|
|
|
|
|
|
def _cart_accessories(self):
|
|
|
|
""" Suggest accessories based on 'Accessory Products' of products in cart """
|
|
|
|
for order in self:
|
|
|
|
accessory_products = order.website_order_line.mapped('product_id.accessory_product_ids').filtered(lambda product: product.website_published)
|
|
|
|
accessory_products -= order.website_order_line.mapped('product_id')
|
|
|
|
return random.sample(accessory_products, len(accessory_products))
|
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def action_recovery_email_send(self):
|
|
|
|
composer_form_view_id = self.env.ref('mail.email_compose_message_wizard_form').id
|
|
|
|
try:
|
|
|
|
default_template = self.env.ref('website_sale.mail_template_sale_cart_recovery', raise_if_not_found=False)
|
|
|
|
default_template_id = default_template.id if default_template else False
|
|
|
|
template_id = int(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_recovery_mail_template_id', default_template_id))
|
|
|
|
except:
|
|
|
|
template_id = False
|
|
|
|
return {
|
|
|
|
'type': 'ir.actions.act_window',
|
|
|
|
'view_type': 'form',
|
|
|
|
'view_mode': 'form',
|
|
|
|
'res_model': 'mail.compose.message',
|
|
|
|
'view_id': composer_form_view_id,
|
|
|
|
'target': 'new',
|
|
|
|
'context': {
|
|
|
|
'default_composition_mode': 'mass_mail' if len(self) > 1 else 'comment',
|
|
|
|
'default_res_id': self.ids[0],
|
|
|
|
'default_model': 'sale.order',
|
|
|
|
'default_use_template': bool(template_id),
|
|
|
|
'default_template_id': template_id,
|
|
|
|
'website_sale_send_recovery_email': True,
|
|
|
|
'active_ids': self.ids,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
def action_mark_as_paid(self):
|
|
|
|
""" Mark directly a sales order as paid if:
|
|
|
|
- State: Quotation Sent, or sales order
|
|
|
|
- Provider: wire transfer or manual config
|
|
|
|
The transaction is marked as done
|
|
|
|
The invoice may be generated and marked as paid if configured in the website settings
|
|
|
|
"""
|
|
|
|
self.ensure_one()
|
|
|
|
if self.can_directly_mark_as_paid:
|
|
|
|
self.action_confirm()
|
|
|
|
if self.env['ir.config_parameter'].sudo().get_param('website_sale.automatic_invoice', default=False):
|
|
|
|
self.payment_tx_id._generate_and_pay_invoice()
|
|
|
|
self.payment_tx_id.state = 'done'
|
|
|
|
else:
|
|
|
|
raise ValidationError(_("The quote should be sent and the payment acquirer type should be manual or wire transfer"))
|
2017-12-22 15:17:53 +01:00
|
|
|
|
|
|
|
@api.multi
|
|
|
|
def _prepare_invoice(self):
|
|
|
|
res = super(SaleOrder, self)._prepare_invoice()
|
|
|
|
res['website_id'] = self.website_id.id
|
|
|
|
return res
|
2018-10-24 07:16:03 +02:00
|
|
|
|
|
|
|
@api.model
|
|
|
|
def send_cart_recovery_mail(self):
|
|
|
|
for val in self.search([('state', 'in', ['draft', 'sent'])]):
|
|
|
|
template = False
|
|
|
|
try:
|
|
|
|
template = self.env.ref(
|
|
|
|
'website_sale.mail_template_sale_cart_recovery',
|
|
|
|
raise_if_not_found=False)
|
|
|
|
except ValueError:
|
|
|
|
pass
|
|
|
|
if val.partner_id.email and template and val.is_abandoned_cart \
|
|
|
|
and not val.cart_recovery_email_sent:
|
|
|
|
template.with_context(lang=val.partner_id.lang).send_mail(
|
|
|
|
val.id, force_send=True, raise_exception=True)
|
|
|
|
val.cart_recovery_email_sent = True
|