mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-26 01:51:59 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -1,6 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import onboarding
|
||||
from . import combo_configurator
|
||||
from . import portal
|
||||
from . import variant
|
||||
from . import product_configurator
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.http import Controller, request, route
|
||||
from odoo.tools import groupby
|
||||
|
||||
|
||||
class SaleComboConfiguratorController(Controller):
|
||||
|
||||
@route(route='/sale/combo_configurator/get_data', type='jsonrpc', auth='user', readonly=True)
|
||||
def sale_combo_configurator_get_data(
|
||||
self,
|
||||
product_tmpl_id,
|
||||
quantity,
|
||||
date,
|
||||
currency_id=None,
|
||||
company_id=None,
|
||||
pricelist_id=None,
|
||||
selected_combo_items=None,
|
||||
**kwargs,
|
||||
):
|
||||
""" Return data about the specified combo product.
|
||||
|
||||
:param int product_tmpl_id: The product for which to get data, as a `product.template` id.
|
||||
:param int quantity: The quantity of the product.
|
||||
:param str date: The date to use to compute prices.
|
||||
:param int|None currency_id: The currency to use to compute prices, as a `res.currency` id.
|
||||
:param int|None company_id: The company to use, as a `res.company` id.
|
||||
:param int|None pricelist_id: The pricelist to use to compute prices, as a
|
||||
`product.pricelist` id.
|
||||
:param list(dict) selected_combo_items: The selected combo items, in the following format:
|
||||
{
|
||||
'id': int,
|
||||
'no_variant_ptav_ids': list(int),
|
||||
'custom_ptavs': list({
|
||||
'id': int,
|
||||
'value': str,
|
||||
}),
|
||||
}
|
||||
:param dict kwargs: Locally unused data passed to `_get_configurator_display_price` and
|
||||
`_get_additional_configurator_data`.
|
||||
:rtype: dict
|
||||
:return: A dict containing data about the combo product.
|
||||
"""
|
||||
if company_id:
|
||||
request.update_context(allowed_company_ids=[company_id])
|
||||
product_template = request.env['product.template'].browse(product_tmpl_id)
|
||||
currency = request.env['res.currency'].browse(currency_id)
|
||||
pricelist = request.env['product.pricelist'].browse(pricelist_id)
|
||||
date = datetime.fromisoformat(date)
|
||||
selected_combo_item_dict = {item['id']: item for item in selected_combo_items or []}
|
||||
|
||||
return {
|
||||
'product_tmpl_id': product_tmpl_id,
|
||||
'display_name': product_template.display_name,
|
||||
'quantity': quantity,
|
||||
'price': product_template._get_configurator_display_price(
|
||||
product_template, quantity, date, currency, pricelist, **kwargs
|
||||
)[0],
|
||||
'combos': [{
|
||||
'id': combo.id,
|
||||
'name': combo.name,
|
||||
'combo_items': [
|
||||
self._get_combo_item_data(
|
||||
combo,
|
||||
combo_item,
|
||||
selected_combo_item_dict.get(combo_item.id, {}),
|
||||
date,
|
||||
currency,
|
||||
pricelist,
|
||||
quantity=quantity,
|
||||
**kwargs,
|
||||
) for combo_item in combo.combo_item_ids if combo_item.product_id.active
|
||||
],
|
||||
} for combo in product_template.sudo().combo_ids],
|
||||
'currency_id': currency_id,
|
||||
**product_template._get_additional_configurator_data(
|
||||
product_template, date, currency, pricelist, quantity=quantity, **kwargs
|
||||
),
|
||||
}
|
||||
|
||||
@route(route='/sale/combo_configurator/get_price', type='jsonrpc', auth='user', readonly=True)
|
||||
def sale_combo_configurator_get_price(
|
||||
self,
|
||||
product_tmpl_id,
|
||||
quantity,
|
||||
date,
|
||||
currency_id=None,
|
||||
company_id=None,
|
||||
pricelist_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
""" Return the price of the specified combo product.
|
||||
|
||||
:param int product_tmpl_id: The product for which to get the price, as a `product.template`
|
||||
id.
|
||||
:param int quantity: The quantity of the product.
|
||||
:param str date: The date to use to compute the price.
|
||||
:param int|None currency_id: The currency to use to compute the price, as a `res.currency`
|
||||
id.
|
||||
:param int|None company_id: The company to use, as a `res.company` id.
|
||||
:param int|None pricelist_id: The pricelist to use to compute the price, as a
|
||||
`product.pricelist` id.
|
||||
:param dict kwargs: Locally unused data passed to `_get_configurator_display_price`.
|
||||
:rtype: float
|
||||
:return: The price of the combo product.
|
||||
"""
|
||||
if company_id:
|
||||
request.update_context(allowed_company_ids=[company_id])
|
||||
product_template = request.env['product.template'].browse(product_tmpl_id)
|
||||
currency = request.env['res.currency'].browse(currency_id)
|
||||
pricelist = request.env['product.pricelist'].browse(pricelist_id)
|
||||
date = datetime.fromisoformat(date)
|
||||
|
||||
return product_template._get_configurator_display_price(
|
||||
product_template, quantity, date, currency, pricelist, **kwargs
|
||||
)[0]
|
||||
|
||||
def _get_combo_item_data(
|
||||
self, combo, combo_item, selected_combo_item, date, currency, pricelist, **kwargs
|
||||
):
|
||||
""" Return the price of the specified combo product.
|
||||
|
||||
:param product.combo combo: The combo for which to get the data.
|
||||
:param product.combo.item combo_item: The combo for which to get the data.
|
||||
:param datetime date: The date to use to compute prices.
|
||||
:param product.pricelist pricelist: The pricelist to use to compute prices.
|
||||
:param dict kwargs: Locally unused data passed to `_get_additional_configurator_data`.
|
||||
:rtype: dict
|
||||
:return: A dict containing data about the combo item.
|
||||
"""
|
||||
# A combo item is configurable if its product variant has:
|
||||
# - Configurable `no_variant` PTALs,
|
||||
# - Or custom PTAVs.
|
||||
is_configurable = any(
|
||||
ptal.attribute_id.create_variant == 'no_variant' and ptal._is_configurable()
|
||||
for ptal in combo_item.product_id.attribute_line_ids
|
||||
) or any(
|
||||
ptav.is_custom for ptav in combo_item.product_id.product_template_attribute_value_ids
|
||||
)
|
||||
# A combo item can be preselected if its combo choice has only one combo item, and that
|
||||
# combo item isn't configurable.
|
||||
is_preselected = len(combo.combo_item_ids) == 1 and not is_configurable
|
||||
|
||||
return {
|
||||
'id': combo_item.id,
|
||||
'extra_price': combo_item.extra_price,
|
||||
'is_preselected': is_preselected,
|
||||
'is_selected': bool(selected_combo_item) or is_preselected,
|
||||
'is_configurable': is_configurable,
|
||||
'product': {
|
||||
'id': combo_item.product_id.id,
|
||||
'product_tmpl_id': combo_item.product_id.product_tmpl_id.id,
|
||||
'display_name': combo_item.product_id.display_name,
|
||||
'ptals': self._get_ptals_data(combo_item.product_id, selected_combo_item),
|
||||
'description': combo_item.product_id.description_sale,
|
||||
**request.env['product.template']._get_additional_configurator_data(
|
||||
combo_item.product_id, date, currency, pricelist, **kwargs
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def _get_ptals_data(self, product, selected_combo_item):
|
||||
""" Return data about the PTALs of the specified product.
|
||||
|
||||
:param product.product product: The product for which to get the PTALs.
|
||||
:param dict selected_combo_item: The selected combo item, in the following format:
|
||||
{
|
||||
'id': int,
|
||||
'no_variant_ptav_ids': list(int),
|
||||
'custom_ptavs': list({
|
||||
'id': int,
|
||||
'value': str,
|
||||
}),
|
||||
}
|
||||
:rtype: list(dict)
|
||||
:return: A list of dicts containing data about the specified product's PTALs.
|
||||
"""
|
||||
variant_ptavs = product.product_template_attribute_value_ids
|
||||
no_variant_ptavs = request.env['product.template.attribute.value'].browse(
|
||||
selected_combo_item.get('no_variant_ptav_ids')
|
||||
)
|
||||
preselected_ptavs = product.attribute_line_ids.filtered(
|
||||
lambda ptal: not ptal._is_configurable()
|
||||
).product_template_value_ids
|
||||
|
||||
ptavs_by_ptal_id = dict(groupby(
|
||||
variant_ptavs | no_variant_ptavs | preselected_ptavs,
|
||||
lambda ptav: ptav.attribute_line_id.id,
|
||||
))
|
||||
|
||||
custom_ptavs = selected_combo_item.get('custom_ptavs', [])
|
||||
custom_value_by_ptav_id = {ptav['id']: ptav['value'] for ptav in custom_ptavs}
|
||||
|
||||
return [{
|
||||
'id': ptal.id,
|
||||
'name': ptal.attribute_id.name,
|
||||
'create_variant': ptal.attribute_id.create_variant,
|
||||
'selected_ptavs': self._get_selected_ptavs_data(
|
||||
ptavs_by_ptal_id.get(ptal.id, []), custom_value_by_ptav_id
|
||||
),
|
||||
} for ptal in product.attribute_line_ids]
|
||||
|
||||
def _get_selected_ptavs_data(self, selected_ptavs, custom_value_by_ptav_id):
|
||||
""" Return data about the selected PTAVs of the specified product.
|
||||
|
||||
:param list(product.template.attribute.value) selected_ptavs: The selected PTAVs.
|
||||
:param dict custom_value_by_ptav_id: A mapping from PTAV ids to custom values.
|
||||
:rtype: list(dict)
|
||||
:return: A list of dicts containing data about the specified PTAL's selected PTAVs.
|
||||
"""
|
||||
return [{
|
||||
'id': ptav.id,
|
||||
'name': ptav.name,
|
||||
'price_extra': ptav.price_extra,
|
||||
'custom_value': custom_value_by_ptav_id.get(ptav.id),
|
||||
} for ptav in selected_ptavs]
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class OnboardingController(http.Controller):
|
||||
|
||||
@http.route('/sales/sale_quotation_onboarding_panel', auth='user', type='json')
|
||||
def sale_quotation_onboarding(self):
|
||||
""" Returns the `banner` for the sale onboarding panel.
|
||||
It can be empty if the user has closed it or if he doesn't have
|
||||
the permission to see it. """
|
||||
|
||||
company = request.env.company
|
||||
if not request.env.is_admin() or \
|
||||
company.sale_quotation_onboarding_state == 'closed':
|
||||
return {}
|
||||
|
||||
return {
|
||||
'html': request.env['ir.qweb']._render('sale.sale_quotation_onboarding_panel', {
|
||||
'company': company,
|
||||
'state': company.get_and_update_sale_quotation_onboarding_state()
|
||||
})
|
||||
}
|
||||
|
|
@ -1,21 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import binascii
|
||||
|
||||
from odoo import fields, http, SUPERUSER_ID, _
|
||||
from odoo import SUPERUSER_ID, _, fields, http
|
||||
from odoo.exceptions import AccessError, MissingError, ValidationError
|
||||
from odoo.fields import Command
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.payment.controllers import portal as payment_portal
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
from odoo.addons.portal.controllers.mail import _message_post_helper
|
||||
from odoo.addons.portal.controllers import portal
|
||||
from odoo.addons.payment.controllers import portal as payment_portal
|
||||
from odoo.addons.portal.controllers.portal import pager as portal_pager
|
||||
|
||||
|
||||
class CustomerPortal(portal.CustomerPortal):
|
||||
class CustomerPortal(payment_portal.PaymentPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
|
|
@ -24,30 +21,28 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
SaleOrder = request.env['sale.order']
|
||||
if 'quotation_count' in counters:
|
||||
values['quotation_count'] = SaleOrder.search_count(self._prepare_quotations_domain(partner)) \
|
||||
if SaleOrder.check_access_rights('read', raise_exception=False) else 0
|
||||
if SaleOrder.has_access('read') else 0
|
||||
if 'order_count' in counters:
|
||||
values['order_count'] = SaleOrder.search_count(self._prepare_orders_domain(partner)) \
|
||||
if SaleOrder.check_access_rights('read', raise_exception=False) else 0
|
||||
values['order_count'] = SaleOrder.search_count(self._prepare_orders_domain(partner), limit=1) \
|
||||
if SaleOrder.has_access('read') else 0
|
||||
|
||||
return values
|
||||
|
||||
def _prepare_quotations_domain(self, partner):
|
||||
return [
|
||||
('message_partner_ids', 'child_of', [partner.commercial_partner_id.id]),
|
||||
('state', 'in', ['sent', 'cancel'])
|
||||
('partner_id', 'child_of', [partner.commercial_partner_id.id]),
|
||||
('state', '=', 'sent')
|
||||
]
|
||||
|
||||
def _prepare_orders_domain(self, partner):
|
||||
return [
|
||||
('message_partner_ids', 'child_of', [partner.commercial_partner_id.id]),
|
||||
('state', 'in', ['sale', 'done'])
|
||||
('partner_id', 'child_of', [partner.commercial_partner_id.id]),
|
||||
('state', '=', 'sale'),
|
||||
]
|
||||
|
||||
def _get_sale_searchbar_sortings(self):
|
||||
return {
|
||||
'date': {'label': _('Order Date'), 'order': 'date_order desc'},
|
||||
'name': {'label': _('Reference'), 'order': 'name'},
|
||||
'stage': {'label': _('Stage'), 'order': 'state'},
|
||||
}
|
||||
|
||||
def _prepare_sale_portal_rendering_values(
|
||||
|
|
@ -75,14 +70,19 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
if date_begin and date_end:
|
||||
domain += [('create_date', '>', date_begin), ('create_date', '<=', date_end)]
|
||||
|
||||
url_args = {'date_begin': date_begin, 'date_end': date_end}
|
||||
|
||||
if len(searchbar_sortings) > 1:
|
||||
url_args['sortby'] = sortby
|
||||
|
||||
pager_values = portal_pager(
|
||||
url=url,
|
||||
total=SaleOrder.search_count(domain),
|
||||
total=SaleOrder.search_count(domain) if SaleOrder.has_access('read') else 0,
|
||||
page=page,
|
||||
step=self._items_per_page,
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
|
||||
url_args=url_args,
|
||||
)
|
||||
orders = SaleOrder.search(domain, order=sort_order, limit=self._items_per_page, offset=pager_values['offset'])
|
||||
orders = SaleOrder.search(domain, order=sort_order, limit=self._items_per_page, offset=pager_values['offset']) if SaleOrder.has_access('read') else SaleOrder
|
||||
|
||||
values.update({
|
||||
'date': date_begin,
|
||||
|
|
@ -91,12 +91,18 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
'page_name': 'quote' if quotation_page else 'order',
|
||||
'pager': pager_values,
|
||||
'default_url': url,
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
})
|
||||
|
||||
if len(searchbar_sortings) > 1:
|
||||
values.update({
|
||||
'sortby': sortby,
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
})
|
||||
|
||||
return values
|
||||
|
||||
# Two following routes cannot be readonly because of the call to `_portal_ensure_token` on all
|
||||
# displayed orders, to assign an access token (triggering a sql update on flush)
|
||||
@http.route(['/my/quotes', '/my/quotes/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_quotes(self, **kwargs):
|
||||
values = self._prepare_sale_portal_rendering_values(quotation_page=True, **kwargs)
|
||||
|
|
@ -110,16 +116,38 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
return request.render("sale.portal_my_orders", values)
|
||||
|
||||
@http.route(['/my/orders/<int:order_id>'], type='http', auth="public", website=True)
|
||||
def portal_order_page(self, order_id, report_type=None, access_token=None, message=False, download=False, **kw):
|
||||
def portal_order_page(
|
||||
self,
|
||||
order_id,
|
||||
report_type=None,
|
||||
access_token=None,
|
||||
message=False,
|
||||
download=False,
|
||||
payment_amount=None,
|
||||
amount_selection=None,
|
||||
**kw
|
||||
):
|
||||
try:
|
||||
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
if report_type in ('html', 'pdf', 'text'):
|
||||
return self._show_report(model=order_sudo, report_type=report_type, report_ref='sale.action_report_saleorder', download=download)
|
||||
payment_amount = self._cast_as_float(payment_amount)
|
||||
prepayment_amount = order_sudo._get_prepayment_required_amount()
|
||||
if payment_amount and payment_amount < prepayment_amount and order_sudo.state != 'sale':
|
||||
raise MissingError(_("The amount is lower than the prepayment amount."))
|
||||
|
||||
if request.env.user.share and access_token:
|
||||
if report_type in ('html', 'pdf', 'text'):
|
||||
return self._show_report(
|
||||
model=order_sudo,
|
||||
report_type=report_type,
|
||||
report_ref='sale.action_report_saleorder',
|
||||
download=download,
|
||||
)
|
||||
|
||||
# If the route is fetched from the link previewer avoid triggering that quotation is viewed.
|
||||
is_link_preview = request.httprequest.headers.get('Odoo-Link-Preview')
|
||||
if request.env.user.share and access_token and is_link_preview != 'True':
|
||||
# If a public/portal user accesses the order with the access token
|
||||
# Log a note on the chatter.
|
||||
today = fields.Date.today().isoformat()
|
||||
|
|
@ -130,33 +158,37 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
# The "Quotation viewed by customer" log note is an information
|
||||
# dedicated to the salesman and shouldn't be translated in the customer/website lgg
|
||||
context = {'lang': order_sudo.user_id.partner_id.lang or order_sudo.company_id.partner_id.lang}
|
||||
msg = _('Quotation viewed by customer %s', order_sudo.partner_id.name if request.env.user._is_public() else request.env.user.partner_id.name)
|
||||
author = order_sudo.partner_id if request.env.user._is_public() else request.env.user.partner_id
|
||||
msg = _('Quotation viewed by customer %s', author.name)
|
||||
del context
|
||||
_message_post_helper(
|
||||
"sale.order",
|
||||
order_sudo.id,
|
||||
message=msg,
|
||||
token=order_sudo.access_token,
|
||||
order_sudo.with_user(SUPERUSER_ID).message_post(
|
||||
body=msg,
|
||||
message_type="notification",
|
||||
subtype_xmlid="mail.mt_note",
|
||||
partner_ids=order_sudo.user_id.sudo().partner_id.ids,
|
||||
subtype_xmlid="sale.mt_order_viewed",
|
||||
)
|
||||
|
||||
backend_url = f'/web#model={order_sudo._name}'\
|
||||
f'&id={order_sudo.id}'\
|
||||
f'&action={order_sudo._get_portal_return_action().id}'\
|
||||
f'&view_type=form'
|
||||
backend_url = f'/odoo/action-{order_sudo._get_portal_return_action().id}/{order_sudo.id}'
|
||||
values = {
|
||||
'sale_order': order_sudo,
|
||||
'product_documents': order_sudo._get_product_documents(),
|
||||
'message': message,
|
||||
'report_type': 'html',
|
||||
'backend_url': backend_url,
|
||||
'res_company': order_sudo.company_id, # Used to display correct company logo
|
||||
'payment_amount': payment_amount,
|
||||
}
|
||||
|
||||
# Payment values
|
||||
if order_sudo._has_to_be_paid():
|
||||
values.update(self._get_payment_values(order_sudo))
|
||||
if order_sudo._has_to_be_paid() or (payment_amount and not order_sudo.is_expired):
|
||||
values.update(self._get_payment_values(
|
||||
order_sudo,
|
||||
is_down_payment=self._determine_is_down_payment(
|
||||
order_sudo, amount_selection, payment_amount
|
||||
),
|
||||
payment_amount=payment_amount,
|
||||
))
|
||||
else:
|
||||
values['payment_amount'] = None
|
||||
|
||||
if order_sudo.state in ('draft', 'sent', 'cancel'):
|
||||
history_session_key = 'my_quotations_history'
|
||||
|
|
@ -164,58 +196,114 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
history_session_key = 'my_orders_history'
|
||||
|
||||
values = self._get_page_view_values(
|
||||
order_sudo, access_token, values, history_session_key, False)
|
||||
order_sudo, access_token, values, history_session_key, False, **kw)
|
||||
|
||||
return request.render('sale.sale_order_portal_template', values)
|
||||
|
||||
def _get_payment_values(self, order_sudo):
|
||||
def _determine_is_down_payment(self, order_sudo, amount_selection, payment_amount):
|
||||
""" Determine whether the current payment is a down payment.
|
||||
|
||||
:param sale.order order_sudo: The sales order being paid.
|
||||
:param str amount_selection: The amount selection specified in the payment link.
|
||||
:param float payment_amount: The amount suggested in the payment link.
|
||||
:return: Whether the current payment is a down payment.
|
||||
:rtype: bool
|
||||
"""
|
||||
if amount_selection == 'down_payment': # The customer chose to pay a down payment.
|
||||
is_down_payment = True
|
||||
elif amount_selection == 'full_amount': # The customer chose to pay the full amount.
|
||||
is_down_payment = False
|
||||
else: # No choice has been specified yet.
|
||||
is_down_payment = (
|
||||
order_sudo.prepayment_percent < 1.0 if payment_amount is None
|
||||
else payment_amount < order_sudo.amount_total
|
||||
)
|
||||
return is_down_payment
|
||||
|
||||
def _get_payment_values(self, order_sudo, is_down_payment=False, payment_amount=None, **kwargs):
|
||||
""" Return the payment-specific QWeb context values.
|
||||
|
||||
:param recordset order_sudo: The sales order being paid, as a `sale.order` record.
|
||||
:param sale.order order_sudo: The sales order being paid.
|
||||
:param bool is_down_payment: Whether the current payment is a down payment.
|
||||
:param float payment_amount: The amount suggested in the payment link.
|
||||
:param dict kwargs: Locally unused data passed to `_get_compatible_providers` and
|
||||
`_get_available_tokens`.
|
||||
:return: The payment-specific values.
|
||||
:rtype: dict
|
||||
"""
|
||||
company = order_sudo.company_id
|
||||
logged_in = not request.env.user._is_public()
|
||||
partner_sudo = request.env.user.partner_id if logged_in else order_sudo.partner_id
|
||||
currency = order_sudo.currency_id
|
||||
|
||||
if is_down_payment:
|
||||
if payment_amount and payment_amount < order_sudo.amount_total:
|
||||
amount = payment_amount
|
||||
else:
|
||||
amount = order_sudo._get_prepayment_required_amount()
|
||||
elif order_sudo.state == 'sale':
|
||||
amount = payment_amount or order_sudo.amount_total
|
||||
else:
|
||||
amount = order_sudo.amount_total
|
||||
|
||||
availability_report = {}
|
||||
# Select all the payment methods and tokens that match the payment context.
|
||||
providers_sudo = request.env['payment.provider'].sudo()._get_compatible_providers(
|
||||
order_sudo.company_id.id,
|
||||
order_sudo.partner_id.id,
|
||||
order_sudo.amount_total,
|
||||
currency_id=order_sudo.currency_id.id,
|
||||
company.id,
|
||||
partner_sudo.id,
|
||||
amount,
|
||||
currency_id=currency.id,
|
||||
sale_order_id=order_sudo.id,
|
||||
) # In sudo mode to read the fields of providers and partner (if not logged in)
|
||||
tokens = request.env['payment.token'].search([
|
||||
('provider_id', 'in', providers_sudo.ids),
|
||||
('partner_id', '=', order_sudo.partner_id.id)
|
||||
]) if logged_in else request.env['payment.token']
|
||||
# Make sure that the partner's company matches the order's company.
|
||||
if not payment_portal.PaymentPortal._can_partner_pay_in_company(
|
||||
order_sudo.partner_id, order_sudo.company_id
|
||||
):
|
||||
providers_sudo = request.env['payment.provider'].sudo()
|
||||
tokens = request.env['payment.token']
|
||||
fees_by_provider = {
|
||||
provider: provider._compute_fees(
|
||||
order_sudo.amount_total,
|
||||
order_sudo.currency_id,
|
||||
order_sudo.partner_id.country_id,
|
||||
) for provider in providers_sudo.filtered('fees_active')
|
||||
report=availability_report,
|
||||
**kwargs,
|
||||
) # In sudo mode to read the fields of providers and partner (if logged out).
|
||||
payment_methods_sudo = request.env['payment.method'].sudo()._get_compatible_payment_methods(
|
||||
providers_sudo.ids,
|
||||
partner_sudo.id,
|
||||
currency_id=currency.id,
|
||||
sale_order_id=order_sudo.id,
|
||||
report=availability_report,
|
||||
**kwargs,
|
||||
) # In sudo mode to read the fields of providers.
|
||||
tokens_sudo = request.env['payment.token'].sudo()._get_available_tokens(
|
||||
providers_sudo.ids, partner_sudo.id, **kwargs
|
||||
) # In sudo mode to read the partner's tokens (if logged out) and provider fields.
|
||||
|
||||
# Make sure that the partner's company matches the invoice's company.
|
||||
company_mismatch = not payment_portal.PaymentPortal._can_partner_pay_in_company(
|
||||
partner_sudo, company
|
||||
)
|
||||
|
||||
portal_page_values = {
|
||||
'company_mismatch': company_mismatch,
|
||||
'expected_company': company,
|
||||
'payment_amount': payment_amount,
|
||||
}
|
||||
return {
|
||||
'providers': providers_sudo,
|
||||
'tokens': tokens,
|
||||
'fees_by_provider': fees_by_provider,
|
||||
'show_tokenize_input': PaymentPortal._compute_show_tokenize_input_mapping(
|
||||
providers_sudo, logged_in=logged_in, sale_order_id=order_sudo.id
|
||||
payment_form_values = {
|
||||
'show_tokenize_input_mapping': PaymentPortal._compute_show_tokenize_input_mapping(
|
||||
providers_sudo, sale_order_id=order_sudo.id
|
||||
),
|
||||
'amount': order_sudo.amount_total,
|
||||
'currency': order_sudo.pricelist_id.currency_id,
|
||||
'partner_id': order_sudo.partner_id.id,
|
||||
'access_token': order_sudo.access_token,
|
||||
}
|
||||
payment_context = {
|
||||
'amount': amount,
|
||||
'currency': currency,
|
||||
'partner_id': partner_sudo.id,
|
||||
'providers_sudo': providers_sudo,
|
||||
'payment_methods_sudo': payment_methods_sudo,
|
||||
'tokens_sudo': tokens_sudo,
|
||||
'availability_report': availability_report,
|
||||
'transaction_route': order_sudo.get_portal_url(suffix='/transaction'),
|
||||
'landing_route': order_sudo.get_portal_url(),
|
||||
'access_token': order_sudo._portal_ensure_token(),
|
||||
}
|
||||
return {
|
||||
**portal_page_values,
|
||||
**payment_form_values,
|
||||
**payment_context,
|
||||
**self._get_extra_payment_form_values(**kwargs),
|
||||
}
|
||||
|
||||
@http.route(['/my/orders/<int:order_id>/accept'], type='json', auth="public", website=True)
|
||||
@http.route(['/my/orders/<int:order_id>/accept'], type='jsonrpc', auth="public", website=True)
|
||||
def portal_quote_accept(self, order_id, access_token=None, name=None, signature=None):
|
||||
# get from query string if not on json param
|
||||
access_token = access_token or request.httprequest.args.get('access_token')
|
||||
|
|
@ -235,27 +323,31 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
'signed_on': fields.Datetime.now(),
|
||||
'signature': signature,
|
||||
})
|
||||
request.env.cr.commit()
|
||||
# flush now to make signature data available to PDF render request
|
||||
request.env.cr.flush()
|
||||
except (TypeError, binascii.Error) as e:
|
||||
return {'error': _('Invalid signature data.')}
|
||||
|
||||
if not order_sudo._has_to_be_paid():
|
||||
order_sudo.action_confirm()
|
||||
order_sudo._send_order_confirmation_mail()
|
||||
order_sudo._validate_order()
|
||||
|
||||
pdf = request.env['ir.actions.report'].sudo()._render_qweb_pdf('sale.action_report_saleorder', [order_sudo.id])[0]
|
||||
|
||||
_message_post_helper(
|
||||
'sale.order',
|
||||
order_sudo.id,
|
||||
_('Order signed by %s', name),
|
||||
order_sudo.message_post(
|
||||
attachments=[('%s.pdf' % order_sudo.name, pdf)],
|
||||
token=access_token,
|
||||
author_id=(
|
||||
order_sudo.partner_id.id
|
||||
if request.env.user._is_public()
|
||||
else request.env.user.partner_id.id
|
||||
),
|
||||
body=_('Order signed by %s', name),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
)
|
||||
|
||||
query_string = '&message=sign_ok'
|
||||
if order_sudo._has_to_be_paid(True):
|
||||
query_string += '#allow_payment=yes'
|
||||
if order_sudo._has_to_be_paid():
|
||||
query_string += '&allow_payment=yes'
|
||||
return {
|
||||
'force_refresh': True,
|
||||
'redirect_url': order_sudo.get_portal_url(query_string=query_string),
|
||||
|
|
@ -270,11 +362,22 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
|
||||
if order_sudo._has_to_be_signed() and decline_message:
|
||||
order_sudo._action_cancel()
|
||||
_message_post_helper(
|
||||
'sale.order',
|
||||
order_sudo.id,
|
||||
decline_message,
|
||||
token=access_token,
|
||||
# The currency is manually cached while in a sudoed environment to prevent an
|
||||
# AccessError. The state of the Sales Order is a dependency of
|
||||
# `untaxed_amount_to_invoice`, which is a monetary field. They require the currency to
|
||||
# ensure the values are saved in the correct format. However, the currency cannot be
|
||||
# read directly during the flush due to access rights, necessitating manual caching.
|
||||
order_sudo.order_line.currency_id
|
||||
|
||||
order_sudo.message_post(
|
||||
author_id=(
|
||||
order_sudo.partner_id.id
|
||||
if request.env.user._is_public()
|
||||
else request.env.user.partner_id.id
|
||||
),
|
||||
body=decline_message,
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
)
|
||||
redirect_url = order_sudo.get_portal_url()
|
||||
else:
|
||||
|
|
@ -282,10 +385,55 @@ class CustomerPortal(portal.CustomerPortal):
|
|||
|
||||
return request.redirect(redirect_url)
|
||||
|
||||
@http.route('/my/orders/<int:order_id>/document/<int:document_id>', type='http', auth='public', readonly=True)
|
||||
def portal_quote_document(self, order_id, document_id, access_token):
|
||||
try:
|
||||
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
document = request.env['product.document'].browse(document_id).sudo().exists()
|
||||
if not document or not document.active:
|
||||
return request.redirect('/my')
|
||||
|
||||
if document not in order_sudo._get_product_documents():
|
||||
return request.redirect('/my')
|
||||
|
||||
return request.env['ir.binary']._get_stream_from(
|
||||
document.ir_attachment_id,
|
||||
).get_response(as_attachment=True)
|
||||
|
||||
@http.route(['/my/orders/<int:order_id>/download_edi'], auth="public", website=True)
|
||||
def portal_my_sale_order_download_edi(self, order_id=None, access_token=None, **kw):
|
||||
""" An endpoint to download EDI file representation."""
|
||||
try:
|
||||
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
builders = order_sudo._get_edi_builders()
|
||||
|
||||
# This handles only one builder for now, more can be added in the future
|
||||
# TODO: add builder choice on modal
|
||||
if len(builders) == 0:
|
||||
return request.redirect('/my')
|
||||
builder = builders[0]
|
||||
|
||||
xml_content = builder._export_order(order_sudo)
|
||||
|
||||
download_name = builder._export_invoice_filename(order_sudo) # works even if it's a SO or PO
|
||||
|
||||
http_headers = [
|
||||
('Content-Type', 'text/xml'),
|
||||
('Content-Length', len(xml_content)),
|
||||
('Content-Disposition', f'attachment; filename={download_name}')
|
||||
]
|
||||
return request.make_response(xml_content, headers=http_headers)
|
||||
|
||||
|
||||
class PaymentPortal(payment_portal.PaymentPortal):
|
||||
|
||||
@http.route('/my/orders/<int:order_id>/transaction', type='json', auth='public')
|
||||
@http.route('/my/orders/<int:order_id>/transaction', type='jsonrpc', auth='public')
|
||||
def portal_order_transaction(self, order_id, access_token, **kwargs):
|
||||
""" Create a draft transaction and return its processing values.
|
||||
|
||||
|
|
@ -304,92 +452,16 @@ class PaymentPortal(payment_portal.PaymentPortal):
|
|||
except AccessError:
|
||||
raise ValidationError(_("The access token is invalid."))
|
||||
|
||||
logged_in = not request.env.user._is_public()
|
||||
partner_sudo = request.env.user.partner_id if logged_in else order_sudo.partner_invoice_id
|
||||
self._validate_transaction_kwargs(kwargs)
|
||||
kwargs.update({
|
||||
'reference_prefix': None, # Allow the reference to be computed based on the order
|
||||
'partner_id': order_sudo.partner_invoice_id.id,
|
||||
'partner_id': partner_sudo.id,
|
||||
'currency_id': order_sudo.currency_id.id,
|
||||
'sale_order_id': order_id, # Include the SO to allow Subscriptions tokenizing the tx
|
||||
})
|
||||
kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values
|
||||
tx_sudo = self._create_transaction(
|
||||
custom_create_values={'sale_order_ids': [Command.set([order_id])]}, **kwargs,
|
||||
)
|
||||
|
||||
return tx_sudo._get_processing_values()
|
||||
|
||||
# Payment overrides
|
||||
|
||||
@http.route()
|
||||
def payment_pay(self, *args, amount=None, sale_order_id=None, access_token=None, **kwargs):
|
||||
""" Override of payment to replace the missing transaction values by that of the sale order.
|
||||
|
||||
This is necessary for the reconciliation as all transaction values, excepted the amount,
|
||||
need to match exactly that of the sale order.
|
||||
|
||||
:param str amount: The (possibly partial) amount to pay used to check the access token
|
||||
:param str sale_order_id: The sale order for which a payment id made, as a `sale.order` id
|
||||
:param str access_token: The access token used to authenticate the partner
|
||||
:return: The result of the parent method
|
||||
:rtype: str
|
||||
:raise: ValidationError if the order id is invalid
|
||||
"""
|
||||
# Cast numeric parameters as int or float and void them if their str value is malformed
|
||||
amount = self._cast_as_float(amount)
|
||||
sale_order_id = self._cast_as_int(sale_order_id)
|
||||
if sale_order_id:
|
||||
order_sudo = request.env['sale.order'].sudo().browse(sale_order_id).exists()
|
||||
if not order_sudo:
|
||||
raise ValidationError(_("The provided parameters are invalid."))
|
||||
|
||||
# Check the access token against the order values. Done after fetching the order as we
|
||||
# need the order fields to check the access token.
|
||||
if not payment_utils.check_access_token(
|
||||
access_token, order_sudo.partner_invoice_id.id, amount, order_sudo.currency_id.id
|
||||
):
|
||||
raise ValidationError(_("The provided parameters are invalid."))
|
||||
|
||||
kwargs.update({
|
||||
'currency_id': order_sudo.currency_id.id,
|
||||
'partner_id': order_sudo.partner_invoice_id.id,
|
||||
'company_id': order_sudo.company_id.id,
|
||||
'sale_order_id': sale_order_id,
|
||||
})
|
||||
return super().payment_pay(*args, amount=amount, access_token=access_token, **kwargs)
|
||||
|
||||
def _get_custom_rendering_context_values(self, sale_order_id=None, **kwargs):
|
||||
""" Override of payment to add the sale order id in the custom rendering context values.
|
||||
|
||||
:param int sale_order_id: The sale order for which a payment id made, as a `sale.order` id
|
||||
:return: The extended rendering context values
|
||||
:rtype: dict
|
||||
"""
|
||||
rendering_context_values = super()._get_custom_rendering_context_values(
|
||||
sale_order_id=sale_order_id, **kwargs
|
||||
)
|
||||
if sale_order_id:
|
||||
rendering_context_values['sale_order_id'] = sale_order_id
|
||||
|
||||
# Interrupt the payment flow if the sales order has been canceled.
|
||||
order_sudo = request.env['sale.order'].sudo().browse(sale_order_id)
|
||||
if order_sudo.state == 'cancel':
|
||||
rendering_context_values['amount'] = 0.0
|
||||
return rendering_context_values
|
||||
|
||||
def _create_transaction(self, *args, sale_order_id=None, custom_create_values=None, **kwargs):
|
||||
""" Override of payment to add the sale order id in the custom create values.
|
||||
|
||||
:param int sale_order_id: The sale order for which a payment id made, as a `sale.order` id
|
||||
:param dict custom_create_values: Additional create values overwriting the default ones
|
||||
:return: The result of the parent method
|
||||
:rtype: recordset of `payment.transaction`
|
||||
"""
|
||||
if sale_order_id:
|
||||
if custom_create_values is None:
|
||||
custom_create_values = {}
|
||||
# As this override is also called if the flow is initiated from sale or website_sale, we
|
||||
# need not to override whatever value these modules could have already set
|
||||
if 'sale_order_ids' not in custom_create_values: # We are in the payment module's flow
|
||||
custom_create_values['sale_order_ids'] = [Command.set([int(sale_order_id)])]
|
||||
|
||||
return super()._create_transaction(
|
||||
*args, sale_order_id=sale_order_id, custom_create_values=custom_create_values, **kwargs
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,437 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.http import Controller, request, route
|
||||
|
||||
|
||||
class SaleProductConfiguratorController(Controller):
|
||||
|
||||
@route(route='/sale/product_configurator/get_values', type='jsonrpc', auth='user', readonly=True)
|
||||
def sale_product_configurator_get_values(
|
||||
self,
|
||||
product_template_id,
|
||||
quantity,
|
||||
currency_id,
|
||||
so_date,
|
||||
product_uom_id=None,
|
||||
company_id=None,
|
||||
pricelist_id=None,
|
||||
ptav_ids=None,
|
||||
only_main_product=False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Return all product information needed for the product configurator.
|
||||
|
||||
:param int product_template_id: The product for which to seek information, as a
|
||||
`product.template` id.
|
||||
:param int quantity: The quantity of the product.
|
||||
:param int currency_id: The currency of the transaction, as a `res.currency` id.
|
||||
:param str so_date: The date of the `sale.order`, to compute the price at the right rate.
|
||||
:param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id.
|
||||
:param int|None company_id: The company to use, as a `res.company` id.
|
||||
:param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id.
|
||||
:param list(int)|None ptav_ids: The combination of the product, as a list of
|
||||
`product.template.attribute.value` ids.
|
||||
:param bool only_main_product: Whether the optional products should be included or not.
|
||||
:param dict kwargs: Locally unused data passed to `_get_product_information`.
|
||||
:rtype: dict
|
||||
:return: A dict containing a list of products and a list of optional products information,
|
||||
generated by :meth:`_get_product_information`.
|
||||
"""
|
||||
if company_id:
|
||||
request.update_context(allowed_company_ids=[company_id])
|
||||
product_template = self._get_product_template(product_template_id)
|
||||
|
||||
combination = request.env['product.template.attribute.value']
|
||||
if ptav_ids:
|
||||
combination = request.env['product.template.attribute.value'].browse(ptav_ids).filtered(
|
||||
lambda ptav: ptav.product_tmpl_id.id == product_template_id
|
||||
)
|
||||
# Set missing attributes (unsaved no_variant attributes, or new attribute on existing product)
|
||||
unconfigured_ptals = (
|
||||
product_template.attribute_line_ids - combination.attribute_line_id).filtered(
|
||||
lambda ptal: ptal.attribute_id.display_type != 'multi')
|
||||
combination += unconfigured_ptals.mapped(
|
||||
lambda ptal: ptal.product_template_value_ids._only_active()[:1]
|
||||
)
|
||||
if not combination:
|
||||
combination = product_template._get_first_possible_combination()
|
||||
currency = request.env['res.currency'].browse(currency_id)
|
||||
pricelist = request.env['product.pricelist'].browse(pricelist_id)
|
||||
so_date = datetime.fromisoformat(so_date)
|
||||
|
||||
return {
|
||||
'products': [
|
||||
dict(
|
||||
**self._get_product_information(
|
||||
product_template,
|
||||
combination,
|
||||
currency,
|
||||
pricelist,
|
||||
so_date,
|
||||
quantity=quantity,
|
||||
product_uom_id=product_uom_id,
|
||||
**kwargs,
|
||||
),
|
||||
)
|
||||
],
|
||||
'optional_products': [
|
||||
dict(
|
||||
**self._get_product_information(
|
||||
optional_product_template,
|
||||
optional_product_template._get_first_possible_combination(
|
||||
parent_combination=combination
|
||||
),
|
||||
currency,
|
||||
pricelist,
|
||||
so_date,
|
||||
# giving all the ptav of the parent product to get all the exclusions
|
||||
parent_combination=product_template.attribute_line_ids.\
|
||||
product_template_value_ids,
|
||||
**kwargs,
|
||||
),
|
||||
parent_product_tmpl_id=product_template.id,
|
||||
) for optional_product_template in product_template.optional_product_ids if
|
||||
self._should_show_product(optional_product_template, combination)
|
||||
] if not only_main_product else [],
|
||||
'currency_id': currency_id,
|
||||
}
|
||||
|
||||
@route(
|
||||
route='/sale/product_configurator/create_product',
|
||||
type='jsonrpc',
|
||||
auth='user',
|
||||
methods=['POST'],
|
||||
)
|
||||
def sale_product_configurator_create_product(self, product_template_id, ptav_ids):
|
||||
"""Create the product when there is a dynamic attribute in the combination.
|
||||
|
||||
:param int product_template_id: The product for which to seek information, as a
|
||||
`product.template` id.
|
||||
:param list(int) ptav_ids: The combination of the product, as a list of
|
||||
`product.template.attribute.value` ids.
|
||||
:rtype: int
|
||||
:return: The product created, as a `product.product` id.
|
||||
"""
|
||||
product_template = self._get_product_template(product_template_id)
|
||||
combination = request.env['product.template.attribute.value'].browse(ptav_ids)
|
||||
product = product_template._create_product_variant(combination)
|
||||
return product.id
|
||||
|
||||
@route(
|
||||
route='/sale/product_configurator/update_combination',
|
||||
type='jsonrpc',
|
||||
auth='user',
|
||||
methods=['POST'],
|
||||
readonly=True,
|
||||
)
|
||||
def sale_product_configurator_update_combination(
|
||||
self,
|
||||
product_template_id,
|
||||
ptav_ids,
|
||||
currency_id,
|
||||
so_date,
|
||||
quantity,
|
||||
product_uom_id=None,
|
||||
company_id=None,
|
||||
pricelist_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Return the updated combination information.
|
||||
|
||||
:param int product_template_id: The product for which to seek information, as a
|
||||
`product.template` id.
|
||||
:param list(int) ptav_ids: The combination of the product, as a list of
|
||||
`product.template.attribute.value` ids.
|
||||
:param int currency_id: The currency of the transaction, as a `res.currency` id.
|
||||
:param str so_date: The date of the `sale.order`, to compute the price at the right rate.
|
||||
:param int quantity: The quantity of the product.
|
||||
:param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id.
|
||||
:param int|None company_id: The company to use, as a `res.company` id.
|
||||
:param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id.
|
||||
:param dict kwargs: Locally unused data passed to `_get_basic_product_information`.
|
||||
:rtype: dict
|
||||
:return: Basic informations about a product, generated by
|
||||
:meth:`_get_basic_product_information`.
|
||||
"""
|
||||
if company_id:
|
||||
request.update_context(allowed_company_ids=[company_id])
|
||||
product_template = self._get_product_template(product_template_id)
|
||||
pricelist = request.env['product.pricelist'].browse(pricelist_id)
|
||||
product_uom = request.env['uom.uom'].browse(product_uom_id)
|
||||
currency = request.env['res.currency'].browse(currency_id)
|
||||
combination = request.env['product.template.attribute.value'].browse(ptav_ids)
|
||||
product = product_template._get_variant_for_combination(combination)
|
||||
|
||||
values = self._get_basic_product_information(
|
||||
product or product_template,
|
||||
pricelist,
|
||||
combination,
|
||||
quantity=quantity or 0.0,
|
||||
uom=product_uom,
|
||||
currency=currency,
|
||||
date=datetime.fromisoformat(so_date),
|
||||
**kwargs,
|
||||
)
|
||||
# Shouldn't be sent client-side
|
||||
values.pop('pricelist_rule_id', None)
|
||||
return values
|
||||
|
||||
@route(
|
||||
route='/sale/product_configurator/get_optional_products',
|
||||
type='jsonrpc',
|
||||
auth='user',
|
||||
readonly=True,
|
||||
)
|
||||
def sale_product_configurator_get_optional_products(
|
||||
self,
|
||||
product_template_id,
|
||||
ptav_ids,
|
||||
parent_ptav_ids,
|
||||
currency_id,
|
||||
so_date,
|
||||
company_id=None,
|
||||
pricelist_id=None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Return information about optional products for the given `product.template`.
|
||||
|
||||
:param int product_template_id: The product for which to seek information, as a
|
||||
`product.template` id.
|
||||
:param list(int) ptav_ids: The combination of the product, as a list of
|
||||
`product.template.attribute.value` ids.
|
||||
:param list(int) parent_ptav_ids: The combination of the parent product, as a list of
|
||||
`product.template.attribute.value` ids.
|
||||
:param int currency_id: The currency of the transaction, as a `res.currency` id.
|
||||
:param str so_date: The date of the `sale.order`, to compute the price at the right rate.
|
||||
:param int|None company_id: The company to use, as a `res.company` id.
|
||||
:param int|None pricelist_id: The pricelist to use, as a `product.pricelist` id.
|
||||
:param dict kwargs: Locally unused data passed to `_get_product_information`.
|
||||
:rtype: [dict]
|
||||
:return: A list of optional products information, generated by
|
||||
:meth:`_get_product_information`.
|
||||
"""
|
||||
if company_id:
|
||||
request.update_context(allowed_company_ids=[company_id])
|
||||
product_template = self._get_product_template(product_template_id)
|
||||
parent_combination = request.env['product.template.attribute.value'].browse(
|
||||
parent_ptav_ids + ptav_ids
|
||||
)
|
||||
currency = request.env['res.currency'].browse(currency_id)
|
||||
pricelist = request.env['product.pricelist'].browse(pricelist_id)
|
||||
return [
|
||||
dict(
|
||||
**self._get_product_information(
|
||||
optional_product_template,
|
||||
optional_product_template._get_first_possible_combination(
|
||||
parent_combination=parent_combination
|
||||
),
|
||||
currency,
|
||||
pricelist,
|
||||
datetime.fromisoformat(so_date),
|
||||
parent_combination=parent_combination,
|
||||
**kwargs,
|
||||
),
|
||||
parent_product_tmpl_id=product_template.id,
|
||||
) for optional_product_template in product_template.optional_product_ids if
|
||||
self._should_show_product(optional_product_template, parent_combination)
|
||||
]
|
||||
|
||||
def _get_product_template(self, product_template_id):
|
||||
return request.env['product.template'].browse(product_template_id)
|
||||
|
||||
def _get_product_information(
|
||||
self,
|
||||
product_template,
|
||||
combination,
|
||||
currency,
|
||||
pricelist,
|
||||
so_date,
|
||||
quantity=1,
|
||||
product_uom_id=None,
|
||||
parent_combination=None,
|
||||
show_packaging=True,
|
||||
**kwargs,
|
||||
):
|
||||
"""Return complete information about a product.
|
||||
|
||||
:param product.template product_template: The product for which to seek information.
|
||||
:param product.template.attribute.value combination: The combination of the product.
|
||||
:param res.currency currency: The currency of the transaction.
|
||||
:param product.pricelist pricelist: The pricelist to use.
|
||||
:param datetime so_date: The date of the `sale.order`, to compute the price at the right
|
||||
rate.
|
||||
:param int quantity: The quantity of the product.
|
||||
:param int|None product_uom_id: The unit of measure of the product, as a `uom.uom` id.
|
||||
:param product.template.attribute.value|None parent_combination: The combination of the
|
||||
parent product.
|
||||
:param dict kwargs: Locally unused data passed to `_get_basic_product_information`.
|
||||
:rtype: dict
|
||||
:return: A dict with the following structure:
|
||||
{
|
||||
'product_tmpl_id': int,
|
||||
'id': int,
|
||||
'description_sale': str|False,
|
||||
'display_name': str,
|
||||
'price': float,
|
||||
'quantity': int
|
||||
'attribute_line': [{
|
||||
'id': int
|
||||
'attribute': {
|
||||
'id': int
|
||||
'name': str
|
||||
'display_type': str
|
||||
},
|
||||
'attribute_value': [{
|
||||
'id': int,
|
||||
'name': str,
|
||||
'price_extra': float,
|
||||
'html_color': str|False,
|
||||
'image': str|False,
|
||||
'is_custom': bool
|
||||
}],
|
||||
'selected_attribute_id': int,
|
||||
}],
|
||||
'exclusions': dict,
|
||||
'archived_combination': dict,
|
||||
'parent_exclusions': dict,
|
||||
'available_uoms': dict (optional),
|
||||
}
|
||||
"""
|
||||
uom = (
|
||||
(product_uom_id and request.env['uom.uom'].browse(product_uom_id))
|
||||
or product_template.uom_id
|
||||
)
|
||||
product = product_template._get_variant_for_combination(combination)
|
||||
attribute_exclusions = product_template._get_attribute_exclusions(
|
||||
parent_combination=parent_combination,
|
||||
combination_ids=combination.ids,
|
||||
)
|
||||
product_or_template = product or product_template
|
||||
ptals = product_template.attribute_line_ids
|
||||
attrs_map = {
|
||||
attr_data['id']: attr_data
|
||||
for attr_data in ptals.attribute_id.read(['id', 'name', 'display_type'])
|
||||
}
|
||||
ptavs = ptals.product_template_value_ids.filtered(lambda p: p.ptav_active or combination and p.id in combination.ids)
|
||||
ptavs_map = dict(zip(ptavs.ids, ptavs.read(['name', 'html_color', 'image', 'is_custom'])))
|
||||
|
||||
values = dict(
|
||||
product_tmpl_id=product_template.id,
|
||||
**self._get_basic_product_information(
|
||||
product_or_template,
|
||||
pricelist,
|
||||
combination,
|
||||
quantity=quantity,
|
||||
uom=uom,
|
||||
currency=currency,
|
||||
date=so_date,
|
||||
**kwargs,
|
||||
),
|
||||
quantity=quantity,
|
||||
uom=uom.read(['id', 'display_name'])[0],
|
||||
attribute_lines=[{
|
||||
'id': ptal.id,
|
||||
'attribute': dict(**attrs_map[ptal.attribute_id.id]),
|
||||
'attribute_values': [
|
||||
dict(
|
||||
**ptavs_map[ptav.id],
|
||||
price_extra=self._get_ptav_price_extra(
|
||||
ptav, currency, so_date, product_or_template
|
||||
),
|
||||
) for ptav in ptal.product_template_value_ids
|
||||
if ptav.ptav_active or (combination and ptav.id in combination.ids)
|
||||
],
|
||||
'selected_attribute_value_ids': combination.filtered(
|
||||
lambda c: ptal in c.attribute_line_id
|
||||
).ids,
|
||||
'create_variant': ptal.attribute_id.create_variant,
|
||||
} for ptal in product_template.attribute_line_ids],
|
||||
exclusions=attribute_exclusions['exclusions'],
|
||||
archived_combinations=attribute_exclusions['archived_combinations'],
|
||||
parent_exclusions=attribute_exclusions['parent_exclusions'],
|
||||
)
|
||||
if show_packaging and product_template._has_multiple_uoms():
|
||||
values['available_uoms'] = product_template._get_available_uoms().read(
|
||||
['id', 'display_name']
|
||||
)
|
||||
# Shouldn't be sent client-side
|
||||
values.pop('pricelist_rule_id', None)
|
||||
return values
|
||||
|
||||
def _get_basic_product_information(self, product_or_template, pricelist, combination, **kwargs):
|
||||
"""Return basic information about a product.
|
||||
|
||||
:param product.product|product.template product_or_template: The product for which to seek
|
||||
information.
|
||||
:param product.pricelist pricelist: The pricelist to use.
|
||||
:param product.template.attribute.value combination: The combination of the product.
|
||||
:param dict kwargs: Locally unused data passed to `_get_product_price`.
|
||||
:rtype: dict
|
||||
:return: A dict with the following structure:
|
||||
{
|
||||
'id': int, # if product_or_template is a record of `product.product`.
|
||||
'description_sale': str|False,
|
||||
'display_name': str,
|
||||
'price': float,
|
||||
}
|
||||
"""
|
||||
basic_information = dict(
|
||||
**product_or_template.read(['description_sale', 'display_name'])[0]
|
||||
)
|
||||
# If the product is a template, check the combination to compute the name to take dynamic
|
||||
# and no_variant attributes into account. Also, drop the id which was auto-included by the
|
||||
# search but isn't relevant since it is supposed to be the id of a `product.product` record.
|
||||
if not product_or_template.is_product_variant:
|
||||
basic_information['id'] = False
|
||||
combination_name = combination._get_combination_name()
|
||||
if combination_name:
|
||||
basic_information.update(
|
||||
display_name=f"{basic_information['display_name']} ({combination_name})"
|
||||
)
|
||||
price, pricelist_rule_id = request.env['product.template']._get_configurator_display_price(
|
||||
product_or_template.with_context(
|
||||
**product_or_template._get_product_price_context(combination)
|
||||
),
|
||||
pricelist=pricelist,
|
||||
**kwargs,
|
||||
)
|
||||
return dict(
|
||||
**basic_information,
|
||||
price=price,
|
||||
pricelist_rule_id=pricelist_rule_id,
|
||||
**request.env['product.template']._get_additional_configurator_data(
|
||||
product_or_template, pricelist=pricelist, **kwargs
|
||||
),
|
||||
)
|
||||
|
||||
def _get_ptav_price_extra(self, ptav, currency, date, product_or_template):
|
||||
"""Return the extra price for a product template attribute value.
|
||||
|
||||
:param product.template.attribute.value ptav: The product template attribute value for which
|
||||
to compute the extra price.
|
||||
:param res.currency currency: The currency to compute the extra price in.
|
||||
:param datetime date: The date to compute the extra price at.
|
||||
:param product.product|product.template product_or_template: The product on which the
|
||||
product template attribute value applies.
|
||||
:rtype: float
|
||||
:return: The extra price for the product template attribute value.
|
||||
"""
|
||||
return ptav.currency_id._convert(
|
||||
ptav.price_extra,
|
||||
currency,
|
||||
request.env.company,
|
||||
date.date(),
|
||||
)
|
||||
|
||||
def _should_show_product(self, product_template, parent_combination):
|
||||
"""Decide whether a product should be shown in the configurator.
|
||||
|
||||
:param product.template product_template: The product being checked.
|
||||
:param product.template.attribute.value parent_combination: The combination of the parent
|
||||
product.
|
||||
:rtype: bool
|
||||
:return: Whether the product should be shown in the configurator.
|
||||
"""
|
||||
return True
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class VariantController(http.Controller):
|
||||
@http.route(['/sale/get_combination_info'], type='json', auth="user", methods=['POST'])
|
||||
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
|
||||
combination = request.env['product.template.attribute.value'].browse(combination)
|
||||
pricelist = self._get_pricelist(pricelist_id)
|
||||
cids = request.httprequest.cookies.get('cids', str(request.env.user.company_id.id))
|
||||
allowed_company_ids = [int(cid) for cid in cids.split(',')]
|
||||
ProductTemplate = request.env['product.template'].with_context(allowed_company_ids=allowed_company_ids)
|
||||
if 'context' in kw:
|
||||
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
|
||||
product_template = ProductTemplate.browse(int(product_template_id))
|
||||
res = product_template._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
|
||||
if 'parent_combination' in kw:
|
||||
parent_combination = request.env['product.template.attribute.value'].browse(kw.get('parent_combination'))
|
||||
if not combination.exists() and product_id:
|
||||
product = request.env['product.product'].browse(int(product_id))
|
||||
if product.exists():
|
||||
combination = product.product_template_attribute_value_ids
|
||||
res.update({
|
||||
'is_combination_possible': product_template._is_combination_possible(combination=combination, parent_combination=parent_combination),
|
||||
'parent_exclusions': product_template._get_parent_attribute_exclusions(parent_combination=parent_combination)
|
||||
})
|
||||
return res
|
||||
|
||||
@http.route(['/sale/create_product_variant'], type='json', auth="user", methods=['POST'])
|
||||
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
|
||||
return request.env['product.template'].browse(int(product_template_id)).create_product_variant(json.loads(product_template_attribute_value_ids))
|
||||
|
||||
def _get_pricelist(self, pricelist_id, pricelist_fallback=False):
|
||||
return request.env['product.pricelist'].browse(int(pricelist_id or 0))
|
||||
Loading…
Add table
Add a link
Reference in a new issue