flectra/addons/website_sale/models/sale_order.py

283 lines
13 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
import logging
import random
from datetime import datetime
from dateutil.relativedelta import relativedelta
from flectra import api, models, fields, _
from flectra.http import request
from flectra.osv import expression
from flectra.exceptions import UserError, ValidationError
_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.',
)
website_id = fields.Many2one('website', string='Website',
help='Website reference for quotation/order.')
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"))
@api.multi
def _prepare_invoice(self):
res = super(SaleOrder, self)._prepare_invoice()
res['website_id'] = self.website_id.id
return res
@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