- Convertir 4 tests de decorador @patch a context manager 'with patch(...)' para evitar RuntimeError en LocalProxy de Werkzeug - Corregir patrón env(user=..., context=dict(...)) en Odoo 18 (sin .with_context()) - Agregar website real al mock para integración con helpers de pricing (_get_pricing_info) - Añadir pickup_date en fixture de existing_order para que _find_recent_draft_order localice correctamente - BUGFIX: Agregar (5,) a order_line para limpiar líneas previas al actualizar pedido existente Resultado: 0 failed, 0 errors de 4 tests en Docker para TestConfirmEskaera_Integration BREAKING: _create_or_update_sale_order ahora limpia las líneas anteriores con (5,) antes de asignar las nuevas cuando se actualiza un pedido existente. Comportamiento previo (duplicación de líneas) era un bug.
3271 lines
128 KiB
Python
3271 lines
128 KiB
Python
# Copyright 2025 Criptomart
|
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from datetime import timedelta
|
|
|
|
from odoo import fields
|
|
from odoo import http
|
|
from odoo.http import request
|
|
|
|
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AplicoopWebsiteSale(WebsiteSale):
|
|
"""Controlador personalizado para website_sale de Aplicoop.
|
|
|
|
Sustitución de la antigua aplicación Aplicoop:
|
|
https://sourceforge.net/projects/aplicoop/
|
|
"""
|
|
|
|
def _get_day_names(self, env=None):
|
|
"""Get translated day names list (0=Monday to 6=Sunday).
|
|
|
|
Gets day names from fields_get() which returns the selection values
|
|
TRANSLATED according to the user's current language preference.
|
|
|
|
Returns: list of 7 translated day names in the user's language
|
|
"""
|
|
if env is None:
|
|
from odoo.http import request
|
|
|
|
env = request.env
|
|
|
|
# Log context language for debugging
|
|
context_lang = env.context.get("lang", "NO_LANG")
|
|
_logger.info("📅 _get_day_names called with context lang: %s", context_lang)
|
|
|
|
group_order_model = env["group.order"]
|
|
# Use fields_get() to get field definitions WITH translations applied
|
|
fields = group_order_model.fields_get(["pickup_day"])
|
|
selection_options = fields.get("pickup_day", {}).get("selection", [])
|
|
|
|
# Log the actual day names returned
|
|
day_names = [name for value, name in selection_options]
|
|
_logger.info(
|
|
"📅 Returning day names: %s",
|
|
day_names[:3] if len(day_names) >= 3 else day_names,
|
|
)
|
|
|
|
return day_names
|
|
|
|
def _get_next_date_for_weekday(self, weekday_num, start_date=None):
|
|
"""Calculate the next occurrence of a given weekday (0=Monday to 6=Sunday).
|
|
|
|
Args:
|
|
weekday_num: int, 0=Monday, 6=Sunday
|
|
start_date: datetime.date, starting point (defaults to today)
|
|
|
|
Returns:
|
|
datetime.date of the next occurrence of that weekday
|
|
"""
|
|
if start_date is None:
|
|
start_date = datetime.now().date()
|
|
|
|
# Convert int weekday (0=Mon) to Python's weekday (0=Mon is same)
|
|
target_weekday = int(weekday_num)
|
|
current_weekday = start_date.weekday()
|
|
|
|
# Calculate days until target weekday
|
|
days_ahead = target_weekday - current_weekday
|
|
if days_ahead <= 0: # Target day has already occurred this week
|
|
days_ahead += 7
|
|
|
|
return start_date + timedelta(days=days_ahead)
|
|
|
|
def _get_detected_language(self, **post):
|
|
"""Detect user language from multiple sources with fallback priority.
|
|
|
|
Priority:
|
|
1. URL parameter 'lang'
|
|
2. POST JSON parameter 'lang'
|
|
3. HTTP Cookie 'lang'
|
|
4. request.env.context['lang']
|
|
5. User's language preference
|
|
6. Default: 'es_ES'
|
|
|
|
Returns: str - language code (e.g., 'es_ES', 'eu_ES', 'en_US')
|
|
"""
|
|
url_lang = request.params.get("lang")
|
|
post_lang = post.get("lang")
|
|
cookie_lang = request.httprequest.cookies.get("lang")
|
|
context_lang = request.env.context.get("lang")
|
|
user_lang = request.env.user.lang or "es_ES"
|
|
|
|
detected = None
|
|
if url_lang:
|
|
detected = url_lang
|
|
elif post_lang:
|
|
detected = post_lang
|
|
elif cookie_lang:
|
|
detected = cookie_lang
|
|
elif context_lang:
|
|
detected = context_lang
|
|
else:
|
|
detected = user_lang
|
|
|
|
_logger.info(
|
|
"🌐 Language detection: url=%s, post=%s, cookie=%s, context=%s, user=%s → DETECTED=%s",
|
|
url_lang,
|
|
post_lang,
|
|
cookie_lang,
|
|
context_lang,
|
|
user_lang,
|
|
detected,
|
|
)
|
|
return detected
|
|
|
|
def _get_translated_labels(self, lang=None):
|
|
"""Get ALL translated UI labels and messages unified.
|
|
|
|
This is the SINGLE SOURCE OF TRUTH for all user-facing messages.
|
|
Every endpoint that returns JSON should use this to get consistent translations.
|
|
|
|
Args:
|
|
lang: str - language code (defaults to detected language)
|
|
|
|
Returns: dict - ALL translated labels and messages
|
|
"""
|
|
if lang is None:
|
|
lang = self._get_detected_language()
|
|
|
|
# Create a new environment with the target language context
|
|
# This is the correct way in Odoo to get translations in a specific language
|
|
env_lang = request.env(context=dict(request.env.context, lang=lang))
|
|
|
|
# Use the imported _ function which respects the environment context
|
|
# The strings must exist in models/js_translations.py
|
|
labels = {
|
|
# ============ SUMMARY TABLE LABELS ============
|
|
"product": env_lang._("Product"),
|
|
"quantity": env_lang._("Quantity"),
|
|
"price": env_lang._("Price"),
|
|
"subtotal": env_lang._("Subtotal"),
|
|
"total": env_lang._("Total"),
|
|
"empty": env_lang._("This order's cart is empty."),
|
|
"empty_cart": env_lang._("Your cart is empty"),
|
|
# ============ ACTION LABELS ============
|
|
"add_to_cart": env_lang._("Add to Cart"),
|
|
"remove_from_cart": env_lang._("Remove from Cart"),
|
|
"remove_item": env_lang._("Remove Item"),
|
|
"save_cart": env_lang._("Save Cart"),
|
|
"reload_cart": env_lang._("Reload Cart"),
|
|
"load_draft": env_lang._("Load Draft"),
|
|
"save_draft": env_lang._("Save Draft"),
|
|
"save_order_as_draft": env_lang._("Save order as draft"),
|
|
"proceed_to_checkout": env_lang._("Proceed to Checkout"),
|
|
"confirm_order": env_lang._("Confirm Order"),
|
|
"back_to_cart": env_lang._("Back to Cart"),
|
|
"toggle_home_delivery": env_lang._("Toggle home delivery"),
|
|
# ============ MODAL CONFIRMATION LABELS ============
|
|
"confirmation": env_lang._("Confirmation"),
|
|
"cancel": env_lang._("Cancel"),
|
|
"confirm": env_lang._("Confirm"),
|
|
"merge": env_lang._("Merge"),
|
|
"replace": env_lang._("Replace"),
|
|
"draft_merge_btn": env_lang._("Merge"),
|
|
"draft_replace_btn": env_lang._("Replace"),
|
|
# ============ SUCCESS MESSAGES ============
|
|
"draft_saved_success": env_lang._("Cart saved as draft successfully"),
|
|
"order_saved_as_draft": env_lang._("Order saved as draft"),
|
|
"draft_loaded_success": env_lang._("Draft order loaded successfully"),
|
|
"draft_merged_success": env_lang._("Draft merged successfully"),
|
|
"draft_replaced_success": env_lang._("Draft replaced successfully"),
|
|
"order_confirmed": env_lang._("Thank you! Your order has been confirmed."),
|
|
"order_loaded": env_lang._("Order loaded"),
|
|
"cart_restored": env_lang._("Your cart has been restored"),
|
|
"qty_updated": env_lang._("Quantity updated"),
|
|
# ============ ERROR MESSAGES ============
|
|
"error_save_draft": env_lang._("Error saving cart"),
|
|
"error_load_draft": env_lang._("Error loading draft"),
|
|
"error_confirm_order": env_lang._("Error confirming order"),
|
|
"error_processing_response": env_lang._("Error processing response"),
|
|
"error_connection": env_lang._("Connection error"),
|
|
"error_unknown": env_lang._("Unknown error"),
|
|
"error_invalid_data": env_lang._("Invalid data provided"),
|
|
"error_order_not_found": env_lang._("Order not found"),
|
|
"error_no_draft_orders": env_lang._(
|
|
"No draft orders found for the current order period"
|
|
),
|
|
"invalid_quantity": env_lang._("Please enter a valid quantity"),
|
|
# ============ CONFIRMATION MESSAGES ============
|
|
"save_draft_confirm": env_lang._(
|
|
"Are you sure you want to save this cart as draft?\n\nItems to save: "
|
|
),
|
|
"save_draft_reload": env_lang._(
|
|
"You will be able to reload this cart later."
|
|
),
|
|
"reload_draft_confirm": env_lang._(
|
|
"Are you sure you want to load your last saved draft?"
|
|
),
|
|
"reload_draft_replace": env_lang._(
|
|
"This will replace the current items in your cart"
|
|
),
|
|
"reload_draft_with": env_lang._("with the saved draft."),
|
|
# ============ DRAFT MODAL LABELS ============
|
|
"draft_already_exists": env_lang._("Draft Already Exists"),
|
|
"draft_exists_message": env_lang._(
|
|
"A saved draft already exists for the current order period."
|
|
),
|
|
"draft_two_options": env_lang._("You have two options:"),
|
|
"draft_option1_title": env_lang._("Option 1: Merge with Existing Draft"),
|
|
"draft_option1_desc": env_lang._(
|
|
"Combine your current cart with the existing draft."
|
|
),
|
|
"draft_existing_items": env_lang._("Existing draft has"),
|
|
"draft_current_items": env_lang._("Current cart has"),
|
|
"draft_items_count": env_lang._("item(s)"),
|
|
"draft_merge_note": env_lang._(
|
|
"Products will be merged by adding quantities. If a product exists in both, quantities will be combined."
|
|
),
|
|
"draft_option2_title": env_lang._("Option 2: Replace with Current Cart"),
|
|
"draft_option2_desc": env_lang._(
|
|
"Delete the old draft and save only the current cart items."
|
|
),
|
|
"draft_replace_warning": env_lang._(
|
|
"The existing draft will be permanently deleted."
|
|
),
|
|
# ============ CHECKOUT PAGE LABELS ============
|
|
"home_delivery": env_lang._("Home Delivery"),
|
|
"delivery_information": env_lang._("Delivery Information"),
|
|
"delivery_info_template": env_lang._(
|
|
"Delivery Information: Your order will be delivered at {pickup_day} {pickup_date}"
|
|
),
|
|
"important": env_lang._("Important"),
|
|
"confirm_order_warning": env_lang._(
|
|
"Once you confirm this order, you will not be able to modify it. Please review carefully before confirming."
|
|
),
|
|
# ============ PORTAL PAGE LABELS ============
|
|
"load_in_cart": env_lang._("Load in Cart"),
|
|
"consumer_group": env_lang._("Consumer Group"),
|
|
"delivery_date": env_lang._("Delivery Date:"),
|
|
"pickup_day_label": env_lang._("Pickup Day"),
|
|
"pickup_date": env_lang._("Pickup Date:"),
|
|
"delivery_notice": env_lang._("Delivery Notice:"),
|
|
"no_delivery_instructions": env_lang._("No special delivery instructions"),
|
|
"pickup_location": env_lang._("Pickup Location:"),
|
|
# ============ DAY NAMES (FOR PORTAL) ============
|
|
"monday": env_lang._("Monday"),
|
|
"tuesday": env_lang._("Tuesday"),
|
|
"wednesday": env_lang._("Wednesday"),
|
|
"thursday": env_lang._("Thursday"),
|
|
"friday": env_lang._("Friday"),
|
|
"saturday": env_lang._("Saturday"),
|
|
"sunday": env_lang._("Sunday"),
|
|
# ============ CATEGORY FILTER ============
|
|
"browse_categories": env_lang._("Browse Product Categories"),
|
|
"all_categories": env_lang._("All categories"),
|
|
"categories": env_lang._("Categories"),
|
|
# ============ SEARCH LABELS ============
|
|
"search": env_lang._("Search"),
|
|
"search_products": env_lang._("Search products..."),
|
|
"no_results": env_lang._("No products found"),
|
|
# ============ MISC ============
|
|
"items": env_lang._("items"),
|
|
"added_to_cart": env_lang._("added to cart"),
|
|
"out_of_stock": env_lang._("Out of stock"),
|
|
# ============ CLEAR CART ============
|
|
"clear_cart": env_lang._("Clear Cart"),
|
|
"clear_cart_confirm": env_lang._(
|
|
"Are you sure you want to clear the cart? This will also cancel any saved draft order."
|
|
),
|
|
"cart_cleared": env_lang._("Cart cleared"),
|
|
"draft_cancelled": env_lang._("draft order cancelled"),
|
|
}
|
|
|
|
return labels
|
|
|
|
def _build_category_hierarchy(self, categories):
|
|
"""Organiza las categorías en una estructura jerárquica padre-hijo.
|
|
|
|
Args:
|
|
categories: product.category recordset
|
|
|
|
Returns:
|
|
list de dicts con estructura: {
|
|
'id': category_id,
|
|
'name': category_name,
|
|
'parent_id': parent_id,
|
|
'children': [list of child dicts]
|
|
}
|
|
"""
|
|
if not categories:
|
|
return []
|
|
|
|
# Crear mapa de categorías por ID
|
|
category_map = {}
|
|
for cat in categories:
|
|
category_map[cat.id] = {
|
|
"id": cat.id,
|
|
"name": cat.name,
|
|
"parent_id": cat.parent_id.id if cat.parent_id else None,
|
|
"children": [],
|
|
}
|
|
|
|
# Identificar categorías raíz (sin padre en la lista) y organizar jerarquía
|
|
roots = []
|
|
for _cat_id, cat_info in category_map.items():
|
|
parent_id = cat_info["parent_id"]
|
|
|
|
# Si el padre no está en la lista de categorías disponibles, es una raíz
|
|
if parent_id is None or parent_id not in category_map:
|
|
roots.append(cat_info)
|
|
else:
|
|
# Agregar a los hijos de su padre
|
|
category_map[parent_id]["children"].append(cat_info)
|
|
|
|
def sort_hierarchy(items):
|
|
items.sort(key=lambda x: x["name"])
|
|
for item in items:
|
|
if item["children"]:
|
|
sort_hierarchy(item["children"])
|
|
|
|
sort_hierarchy(roots)
|
|
return roots
|
|
|
|
# ========== PHASE 1: HELPER METHODS FOR VALIDATION AND CONFIGURATION ==========
|
|
|
|
def _resolve_pricelist(self):
|
|
"""Resolve the pricelist to use for pricing.
|
|
|
|
Resolution order:
|
|
1. Aplicoop configured pricelist (from settings)
|
|
2. Website current pricelist
|
|
3. First active pricelist (fallback)
|
|
|
|
Returns:
|
|
product.pricelist record or False if none found
|
|
"""
|
|
env = request.env
|
|
website = request.website
|
|
pricelist = None
|
|
|
|
# 1) Configured pricelist from settings
|
|
try:
|
|
param_value = (
|
|
env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.pricelist_id")
|
|
)
|
|
if param_value:
|
|
pricelist = (
|
|
env["product.pricelist"].browse(int(param_value)).exists() or None
|
|
)
|
|
except Exception as e:
|
|
_logger.warning("_resolve_pricelist: error reading config param: %s", e)
|
|
|
|
# 2) Website current pricelist
|
|
if not pricelist:
|
|
try:
|
|
pricelist = website._get_current_pricelist()
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"_resolve_pricelist: fallback to website pricelist failed: %s", e
|
|
)
|
|
|
|
# 3) First active pricelist as fallback
|
|
if not pricelist:
|
|
pricelist = env["product.pricelist"].sudo().search([], limit=1)
|
|
|
|
return pricelist
|
|
|
|
def _prepare_product_display_info(self, product, product_price_info):
|
|
"""Build display info for a product using precomputed prices.
|
|
|
|
Args:
|
|
product (product.product): Product variant.
|
|
product_price_info: dict with price data keyed by product.id.
|
|
|
|
Returns:
|
|
dict with display_price, safe_uom_category, quantity_step,
|
|
price_unit_suffix and translated accessibility labels.
|
|
"""
|
|
price_data = product_price_info.get(product.id, {})
|
|
price = (
|
|
price_data.get("price", product.list_price)
|
|
if price_data
|
|
else product.list_price
|
|
)
|
|
price_safe = float(price) if price else 0.0
|
|
|
|
uom_category_name = ""
|
|
quantity_step = 1
|
|
price_unit_suffix = ""
|
|
|
|
if product.uom_id:
|
|
uom = product.uom_id.sudo()
|
|
if uom.category_id:
|
|
uom_category_name = uom.category_id.sudo().name or ""
|
|
try:
|
|
ir_model_data = request.env["ir.model.data"].sudo()
|
|
external_id = ir_model_data.search(
|
|
[
|
|
("model", "=", "uom.category"),
|
|
("res_id", "=", uom.category_id.id),
|
|
],
|
|
limit=1,
|
|
)
|
|
|
|
if external_id:
|
|
fractional_categories = [
|
|
"uom.product_uom_categ_kgm",
|
|
"uom.product_uom_categ_vol",
|
|
"uom.uom_categ_length",
|
|
"uom.uom_categ_surface",
|
|
]
|
|
full_xmlid = f"{external_id.module}.{external_id.name}"
|
|
if full_xmlid in fractional_categories:
|
|
quantity_step = 0.1
|
|
if full_xmlid == "uom.product_uom_categ_kgm":
|
|
price_unit_suffix = "/Kg"
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"_prepare_product_display_info: Error detecting UoM category XML ID for product %s: %s",
|
|
product.id,
|
|
str(e),
|
|
)
|
|
|
|
out_of_stock_label = request.env._("Out of stock")
|
|
add_to_cart_label = request.env._(
|
|
"Add %(product_name)s to cart", product_name=product.name
|
|
)
|
|
|
|
return {
|
|
"display_price": price_safe,
|
|
"safe_uom_category": uom_category_name,
|
|
"quantity_step": quantity_step,
|
|
"price_unit_suffix": price_unit_suffix,
|
|
"out_of_stock_label": out_of_stock_label,
|
|
"add_to_cart_label": add_to_cart_label,
|
|
}
|
|
|
|
def _get_pricing_info(self, product, pricelist, quantity=1.0, partner=None):
|
|
"""Compute pricing with taxes like website_sale but using a given pricelist.
|
|
|
|
Returns a dict with:
|
|
- price_unit: raw pricelist price (before taxes), suitable for sale.order.line
|
|
- price: display price (tax included/excluded per website setting)
|
|
- list_price: display list price (pre-discount) with same tax display
|
|
- has_discounted_price: bool
|
|
"""
|
|
|
|
try:
|
|
env = request.env
|
|
website = request.website
|
|
except RuntimeError:
|
|
env = product.env
|
|
website = env["website"].get_current_website()
|
|
|
|
partner = partner or env.user.partner_id
|
|
currency = pricelist.currency_id
|
|
company = website.company_id or product.company_id or env.company
|
|
|
|
price, rule_id = pricelist._get_product_price_rule(
|
|
product=product,
|
|
quantity=quantity,
|
|
target_currency=currency,
|
|
)
|
|
|
|
price_before_discount = price
|
|
pricelist_item = env["product.pricelist.item"].browse(rule_id)
|
|
if pricelist_item and pricelist_item._show_discount_on_shop():
|
|
price_before_discount = pricelist_item._compute_price_before_discount(
|
|
product=product,
|
|
quantity=quantity or 1.0,
|
|
date=fields.Date.context_today(pricelist),
|
|
uom=product.uom_id,
|
|
currency=currency,
|
|
)
|
|
|
|
has_discounted_price = price_before_discount > price
|
|
|
|
fiscal_position = website.fiscal_position_id.sudo()
|
|
product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company)
|
|
taxes = (
|
|
fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes
|
|
)
|
|
tax_display = "total_included"
|
|
|
|
def compute_display(amount):
|
|
if not taxes:
|
|
return amount
|
|
return taxes.compute_all(amount, currency, 1, product, partner)[tax_display]
|
|
|
|
display_price = compute_display(price)
|
|
display_list_price = compute_display(price_before_discount)
|
|
|
|
return {
|
|
"price_unit": price,
|
|
"price": display_price,
|
|
"list_price": display_list_price,
|
|
"has_discounted_price": has_discounted_price,
|
|
"discount": display_list_price - display_price,
|
|
"tax_included": tax_display == "total_included",
|
|
}
|
|
|
|
def _compute_price_info(self, products, pricelist):
|
|
"""Compute price info dict for a list of products using the given pricelist.
|
|
|
|
Returns a dict keyed by product.id with pricing metadata used by templates.
|
|
"""
|
|
product_price_info = {}
|
|
for product in products:
|
|
product_variant = (
|
|
product.product_variant_ids[0] if product.product_variant_ids else False
|
|
)
|
|
if product_variant and pricelist:
|
|
try:
|
|
pricing = self._get_pricing_info(
|
|
product_variant,
|
|
pricelist,
|
|
quantity=1.0,
|
|
partner=request.env.user.partner_id,
|
|
)
|
|
product_price_info[product.id] = pricing
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"_compute_price_info: Error getting price for product %s (id=%s): %s. Using list_price fallback.",
|
|
product.name,
|
|
product.id,
|
|
str(e),
|
|
)
|
|
product_price_info[product.id] = {
|
|
"price_unit": product.list_price,
|
|
"price": product.list_price,
|
|
"list_price": product.list_price,
|
|
"has_discounted_price": False,
|
|
"discount": 0.0,
|
|
"tax_included": request.website.show_line_subtotals_tax_selection
|
|
!= "tax_excluded",
|
|
}
|
|
else:
|
|
product_price_info[product.id] = {
|
|
"price_unit": product.list_price,
|
|
"price": product.list_price,
|
|
"list_price": product.list_price,
|
|
"has_discounted_price": False,
|
|
"discount": 0.0,
|
|
"tax_included": request.website.show_line_subtotals_tax_selection
|
|
!= "tax_excluded",
|
|
}
|
|
return product_price_info
|
|
|
|
def _get_product_supplier_info(self, products):
|
|
"""Return a mapping product.id -> 'Supplier (City)' string for display."""
|
|
product_supplier_info = {}
|
|
for product in products:
|
|
supplier_name = ""
|
|
if product.seller_ids:
|
|
partner = product.seller_ids[0].partner_id.sudo()
|
|
supplier_name = partner.name or ""
|
|
if partner.city:
|
|
supplier_name += f" ({partner.city})"
|
|
product_supplier_info[product.id] = supplier_name
|
|
return product_supplier_info
|
|
|
|
def _get_delivery_product_display_price(self, delivery_product, pricelist=None):
|
|
"""Return delivery product price for display (list_price + taxes).
|
|
|
|
Important: delivery cost must be based on product list_price and taxes,
|
|
not on commercial pricelist rules (discounts/markups), to avoid inflating
|
|
shipping price unexpectedly.
|
|
"""
|
|
if not delivery_product:
|
|
return 5.74
|
|
|
|
try:
|
|
base_price = float(delivery_product.list_price or 0.0)
|
|
website = request.website
|
|
partner = request.env.user.partner_id
|
|
company = (
|
|
website.company_id or delivery_product.company_id or request.env.company
|
|
)
|
|
|
|
product_taxes = delivery_product.sudo().taxes_id._filter_taxes_by_company(
|
|
company
|
|
)
|
|
fiscal_position = website.fiscal_position_id.sudo()
|
|
taxes = (
|
|
fiscal_position.map_tax(product_taxes)
|
|
if product_taxes
|
|
else product_taxes
|
|
)
|
|
|
|
if not taxes:
|
|
return base_price
|
|
|
|
# Use website currency for display computation.
|
|
currency = website.currency_id
|
|
totals = taxes.compute_all(
|
|
base_price,
|
|
currency=currency,
|
|
quantity=1.0,
|
|
product=delivery_product,
|
|
partner=partner,
|
|
)
|
|
return float(totals.get("total_included", base_price) or 0.0)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"_get_delivery_product_display_price: Error computing delivery display price for product %s (id=%s): %s. Using list_price fallback.",
|
|
delivery_product.name,
|
|
delivery_product.id,
|
|
str(e),
|
|
)
|
|
return float(delivery_product.list_price or 0.0)
|
|
|
|
def _filter_products(self, all_products, post, group_order):
|
|
"""Apply search and category filters to the complete product set and compute available tags.
|
|
|
|
Returns: (filtered_products, available_tags, search_query, category_filter)
|
|
"""
|
|
search_query = post.get("search", "").strip()
|
|
category_filter = post.get("category", "0")
|
|
|
|
# Start with complete set
|
|
filtered_products = all_products
|
|
|
|
# Apply search
|
|
if search_query:
|
|
filtered_products = filtered_products.filtered(
|
|
lambda p: search_query.lower() in p.name.lower()
|
|
or search_query.lower() in (p.description or "").lower()
|
|
)
|
|
_logger.info(
|
|
'Filter: search "%s" - found %d of %d',
|
|
search_query,
|
|
len(filtered_products),
|
|
len(all_products),
|
|
)
|
|
|
|
# Apply category filter
|
|
if category_filter != "0":
|
|
try:
|
|
category_id = int(category_filter)
|
|
selected_category = (
|
|
request.env["product.category"].sudo().browse(category_id)
|
|
)
|
|
if selected_category.exists():
|
|
all_category_ids = [category_id]
|
|
|
|
def get_all_children(category):
|
|
for child in category.child_id:
|
|
all_category_ids.append(child.id)
|
|
get_all_children(child)
|
|
|
|
get_all_children(selected_category)
|
|
|
|
_logger.info(
|
|
"Filter: category %d (%s) - collected %d category IDs (including children): %s",
|
|
category_id,
|
|
selected_category.name,
|
|
len(all_category_ids),
|
|
all_category_ids,
|
|
)
|
|
|
|
# Count products in filtered_products that match these categories
|
|
before_count = len(filtered_products)
|
|
products_in_categories = filtered_products.filtered(
|
|
lambda p: p.categ_id.id in all_category_ids
|
|
)
|
|
|
|
_logger.info(
|
|
"Filter: category %d - before filter: %d products, after filter: %d products",
|
|
category_id,
|
|
before_count,
|
|
len(products_in_categories),
|
|
)
|
|
|
|
# Log categories of products that were filtered out (for debugging)
|
|
if len(products_in_categories) == 0 and before_count > 0:
|
|
product_categories = set()
|
|
for p in filtered_products[:10]: # Only first 10 to avoid spam
|
|
product_categories.add((p.categ_id.id, p.categ_id.name))
|
|
_logger.warning(
|
|
"Filter: category %d - ALL PRODUCTS FILTERED OUT! Sample product categories: %s",
|
|
category_id,
|
|
list(product_categories),
|
|
)
|
|
|
|
filtered_products = products_in_categories
|
|
_logger.info(
|
|
"Filter: category %d - found %d of %d total",
|
|
category_id,
|
|
len(filtered_products),
|
|
len(all_products),
|
|
)
|
|
except (ValueError, TypeError) as e:
|
|
_logger.warning("Filter: invalid category filter: %s", str(e))
|
|
|
|
# Compute available tags
|
|
available_tags_dict = {}
|
|
for product in filtered_products:
|
|
for tag in product.product_tag_ids:
|
|
is_visible = getattr(tag, "visible_on_ecommerce", True)
|
|
if not is_visible:
|
|
continue
|
|
if tag.id not in available_tags_dict:
|
|
tag_color = tag.color if tag.color else None
|
|
available_tags_dict[tag.id] = {
|
|
"id": tag.id,
|
|
"name": tag.name,
|
|
"color": tag_color,
|
|
"count": 0,
|
|
}
|
|
available_tags_dict[tag.id]["count"] += 1
|
|
|
|
available_tags = sorted(available_tags_dict.values(), key=lambda t: t["name"])
|
|
_logger.info(
|
|
"Filter: found %d available tags for %d filtered products",
|
|
len(available_tags),
|
|
len(filtered_products),
|
|
)
|
|
|
|
return filtered_products, available_tags, search_query, category_filter
|
|
|
|
def _validate_confirm_request(self, data):
|
|
"""Validate all requirements for confirm order request.
|
|
|
|
Validates:
|
|
- order_id exists and is valid integer
|
|
- group.order exists and is in open state
|
|
- user has associated partner_id
|
|
- items list is not empty
|
|
|
|
Args:
|
|
data: dict with 'order_id' and 'items' keys
|
|
|
|
# Verify that the group.order exists (use sudo for read checks)
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
|
|
Raises:
|
|
ValueError: if any validation fails
|
|
"""
|
|
# Validate order_id
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
raise ValueError("order_id is required") from None
|
|
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError) as err:
|
|
raise ValueError(f"Invalid order_id format: {order_id}") from err
|
|
|
|
# Verify that the group.order exists (use sudo for read checks)
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
raise ValueError(f"Order {order_id} not found") from None
|
|
|
|
# Verify that the order is in open state
|
|
if group_order.state != "open":
|
|
raise ValueError("Order is not available (not in open state)") from None
|
|
|
|
# Validate user has partner_id
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
raise ValueError("User has no associated partner") from None
|
|
|
|
self._validate_user_group_access(group_order, current_user)
|
|
|
|
# Validate items
|
|
items = data.get("items", [])
|
|
if not items:
|
|
raise ValueError("No items in cart") from None
|
|
|
|
_logger.info(
|
|
"_validate_confirm_request: Valid request for order %d with %d items",
|
|
order_id,
|
|
len(items),
|
|
)
|
|
|
|
return order_id, group_order, current_user, items
|
|
|
|
def _validate_draft_request(self, data):
|
|
"""Validate all requirements for draft order request.
|
|
|
|
Validates:
|
|
- order_id exists and is valid integer
|
|
- group.order exists
|
|
- user has associated partner_id
|
|
- items list is not empty
|
|
|
|
Args:
|
|
data: dict with 'order_id' and 'items' keys
|
|
|
|
Returns:
|
|
tuple: (order_id, group_order, current_user, items, merge_action, existing_draft_id)
|
|
|
|
Raises:
|
|
ValueError: if any validation fails
|
|
"""
|
|
|
|
# Validate order_id
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
raise ValueError("order_id is required")
|
|
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError) as err:
|
|
raise ValueError(f"Invalid order_id format: {order_id}") from err
|
|
|
|
# Verify that the group.order exists (use sudo for read checks)
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
raise ValueError(f"Order {order_id} not found")
|
|
|
|
# Validate user has partner_id
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
raise ValueError("User has no associated partner")
|
|
|
|
self._validate_user_group_access(group_order, current_user)
|
|
|
|
# Validate items
|
|
items = data.get("items", [])
|
|
if not items:
|
|
raise ValueError("No items in cart")
|
|
|
|
# Get optional merge/replace parameters
|
|
merge_action = data.get("merge_action")
|
|
existing_draft_id = data.get("existing_draft_id")
|
|
|
|
_logger.info(
|
|
"_validate_draft_request: Valid request for order %d with %d items (merge_action=%s)",
|
|
order_id,
|
|
len(items),
|
|
merge_action,
|
|
)
|
|
|
|
return (
|
|
order_id,
|
|
group_order,
|
|
current_user,
|
|
items,
|
|
merge_action,
|
|
existing_draft_id,
|
|
)
|
|
|
|
def _validate_confirm_json(self, data):
|
|
"""Validate JSON data and order for confirm_eskaera endpoint.
|
|
|
|
Validates:
|
|
- order_id is present and valid integer
|
|
- group.order exists and is in 'open' state
|
|
- user has associated partner_id
|
|
- items list is not empty
|
|
|
|
Args:
|
|
data: dict with 'order_id' and 'items' keys
|
|
|
|
Returns:
|
|
tuple: (order_id, group_order, current_user, items, is_delivery)
|
|
|
|
Raises:
|
|
ValueError: if any validation fails
|
|
"""
|
|
# Validate order_id
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
raise ValueError("order_id is required")
|
|
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError) as err:
|
|
raise ValueError(f"Invalid order_id format: {order_id}") from err
|
|
|
|
# Verify that the order exists (use sudo for read checks)
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
raise ValueError(f"Order {order_id} not found")
|
|
|
|
# Verify that the order is open
|
|
if group_order.state != "open":
|
|
raise ValueError(f"Order is {group_order.state}")
|
|
|
|
# Validate user has partner_id
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
raise ValueError("User has no associated partner")
|
|
|
|
self._validate_user_group_access(group_order, current_user)
|
|
|
|
# Validate items
|
|
items = data.get("items", [])
|
|
if not items:
|
|
raise ValueError("No items in cart")
|
|
|
|
# Get delivery flag (normalize to strict boolean)
|
|
is_delivery = self._to_bool(data.get("is_delivery", False))
|
|
|
|
_logger.info(
|
|
"_validate_confirm_json: Valid request for order %d with %d items (is_delivery=%s)",
|
|
order_id,
|
|
len(items),
|
|
is_delivery,
|
|
)
|
|
|
|
return order_id, group_order, current_user, items, is_delivery
|
|
|
|
def _to_bool(self, value):
|
|
"""Convert common JSON/form boolean representations to bool.
|
|
|
|
Accepts booleans, numeric values and common string variants.
|
|
"""
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return value != 0
|
|
if isinstance(value, str):
|
|
normalized = value.strip().lower()
|
|
if normalized in {"1", "true", "t", "yes", "y", "on"}:
|
|
return True
|
|
if normalized in {"0", "false", "f", "no", "n", "off", ""}:
|
|
return False
|
|
return bool(value)
|
|
|
|
def _get_effective_delivery_context(self, group_order, is_delivery):
|
|
"""Return effective home delivery flag and commitment date.
|
|
|
|
Delivery is effective only when requested by the user and enabled
|
|
in the group order configuration.
|
|
"""
|
|
delivery_requested = self._to_bool(is_delivery)
|
|
delivery_enabled = bool(group_order and group_order.home_delivery)
|
|
effective_home_delivery = delivery_requested and delivery_enabled
|
|
|
|
if delivery_requested and not delivery_enabled:
|
|
_logger.info(
|
|
"Delivery requested but disabled in group order %s; using pickup flow",
|
|
group_order.id if group_order else "N/A",
|
|
)
|
|
|
|
if effective_home_delivery:
|
|
commitment_date = group_order.delivery_date or group_order.pickup_date
|
|
else:
|
|
commitment_date = group_order.pickup_date if group_order else False
|
|
|
|
return effective_home_delivery, commitment_date
|
|
|
|
def _process_cart_items(self, items, group_order, pricelist=None):
|
|
"""Process cart items and build sale.order line data.
|
|
|
|
Args:
|
|
items: list of item dicts with product_id, quantity, product_price
|
|
group_order: group.order record for context
|
|
|
|
Returns:
|
|
list of (0, 0, line_dict) tuples ready for sale.order creation
|
|
|
|
Raises:
|
|
ValueError: if no valid items after processing
|
|
"""
|
|
sale_order_lines = []
|
|
pricelist = pricelist or self._resolve_pricelist()
|
|
partner = request.env.user.partner_id
|
|
|
|
for item in items:
|
|
try:
|
|
product_id = int(item.get("product_id"))
|
|
quantity = float(item.get("quantity", 1))
|
|
|
|
product = request.env["product.product"].sudo().browse(product_id)
|
|
if not product.exists():
|
|
_logger.warning(
|
|
"_process_cart_items: Product %d does not exist", product_id
|
|
)
|
|
continue
|
|
|
|
# Get product name in user's language context
|
|
product_in_lang = product.with_context(lang=request.env.lang)
|
|
product_name = product_in_lang.name
|
|
|
|
pricing = self._get_pricing_info(
|
|
product,
|
|
pricelist,
|
|
quantity=quantity,
|
|
partner=partner,
|
|
)
|
|
|
|
line_data = {
|
|
"product_id": product_id,
|
|
"product_uom_qty": quantity,
|
|
"price_unit": pricing.get("price_unit", product.list_price),
|
|
"name": product_name, # Force the translated product name
|
|
}
|
|
_logger.info("_process_cart_items: Adding line: %s", line_data)
|
|
sale_order_lines.append((0, 0, line_data))
|
|
except (ValueError, TypeError) as e:
|
|
_logger.warning(
|
|
"_process_cart_items: Error processing item %s: %s",
|
|
item,
|
|
str(e),
|
|
)
|
|
continue
|
|
|
|
if not sale_order_lines:
|
|
raise ValueError("No valid items in cart")
|
|
|
|
_logger.info(
|
|
"_process_cart_items: Created %d valid lines", len(sale_order_lines)
|
|
)
|
|
return sale_order_lines
|
|
|
|
def _get_salesperson_for_order(self, partner):
|
|
"""Get the salesperson (user_id) for creating sale orders.
|
|
|
|
For portal users without write access to sale.order, we need to create
|
|
the order as the assigned salesperson or with sudo().
|
|
|
|
Args:
|
|
partner: res.partner record
|
|
|
|
Returns:
|
|
res.users record (salesperson) or False
|
|
"""
|
|
# First check if partner has an assigned salesperson
|
|
if partner.user_id and not partner.user_id._is_public():
|
|
return partner.user_id
|
|
|
|
# Fallback to commercial partner's salesperson
|
|
commercial_partner = partner.commercial_partner_id
|
|
if commercial_partner.user_id and not commercial_partner.user_id._is_public():
|
|
return commercial_partner.user_id
|
|
|
|
# No salesperson found
|
|
return False
|
|
|
|
def _get_consumer_group_for_user(self, group_order, current_user):
|
|
"""Return the matching consumer group for the user in this group order."""
|
|
partner = current_user.partner_id
|
|
if not partner or not group_order:
|
|
return False
|
|
|
|
user_group_ids = set(partner.group_ids.ids)
|
|
for consumer_group in group_order.group_ids:
|
|
if consumer_group.id in user_group_ids:
|
|
return consumer_group.id
|
|
|
|
_logger.warning(
|
|
"_get_consumer_group_for_user: User %s (%s) is not member of any "
|
|
"consumer group in order %s. user_groups=%s, order_groups=%s",
|
|
current_user.name,
|
|
current_user.id,
|
|
group_order.id,
|
|
sorted(user_group_ids),
|
|
group_order.group_ids.ids,
|
|
)
|
|
return False
|
|
|
|
def _validate_user_group_access(self, group_order, current_user):
|
|
"""Ensure the user belongs to at least one consumer group of the order."""
|
|
consumer_group_id = self._get_consumer_group_for_user(group_order, current_user)
|
|
if not consumer_group_id:
|
|
raise ValueError("User is not a member of any consumer group in this order")
|
|
return consumer_group_id
|
|
|
|
def _create_or_update_sale_order(
|
|
self,
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
is_delivery,
|
|
commitment_date=None,
|
|
existing_order=None,
|
|
):
|
|
"""Create or update a sale.order from prepared sale_order_lines.
|
|
|
|
Returns the sale.order record.
|
|
"""
|
|
consumer_group_id = self._validate_user_group_access(group_order, current_user)
|
|
|
|
_logger.info(
|
|
"[CONSUMER_GROUP DEBUG] _create_or_update_sale_order: "
|
|
"group_order=%s, group_order.group_ids=%s, consumer_group_id=%s",
|
|
group_order.id,
|
|
group_order.group_ids.ids,
|
|
consumer_group_id,
|
|
)
|
|
|
|
effective_home_delivery, calculated_commitment_date = (
|
|
self._get_effective_delivery_context(group_order, is_delivery)
|
|
)
|
|
|
|
# Explicit commitment_date wins; otherwise use calculated value
|
|
if not commitment_date:
|
|
commitment_date = calculated_commitment_date
|
|
|
|
if existing_order:
|
|
# Update existing order with new lines and propagate fields
|
|
# Use sudo() to avoid permission issues with portal users
|
|
existing_order_sudo = existing_order.sudo()
|
|
# (5,) clears all existing lines before adding the new ones
|
|
existing_order_sudo.order_line = [(5,)] + sale_order_lines
|
|
if not existing_order_sudo.group_order_id:
|
|
existing_order_sudo.group_order_id = group_order.id
|
|
existing_order_sudo.pickup_day = group_order.pickup_day
|
|
existing_order_sudo.pickup_date = group_order.pickup_date
|
|
existing_order_sudo.home_delivery = effective_home_delivery
|
|
existing_order_sudo.consumer_group_id = consumer_group_id
|
|
if commitment_date:
|
|
existing_order_sudo.commitment_date = commitment_date
|
|
_logger.info(
|
|
"Updated existing sale.order %d: commitment_date=%s, home_delivery=%s, consumer_group_id=%s",
|
|
existing_order.id,
|
|
commitment_date,
|
|
effective_home_delivery,
|
|
consumer_group_id,
|
|
)
|
|
return existing_order
|
|
|
|
# Create new order values dict
|
|
order_vals = {
|
|
"partner_id": current_user.partner_id.id,
|
|
"order_line": sale_order_lines,
|
|
"group_order_id": group_order.id,
|
|
"pickup_day": group_order.pickup_day,
|
|
"pickup_date": group_order.pickup_date,
|
|
"home_delivery": effective_home_delivery,
|
|
"consumer_group_id": consumer_group_id,
|
|
}
|
|
if commitment_date:
|
|
order_vals["commitment_date"] = commitment_date
|
|
|
|
# Get salesperson for order creation (portal users need this)
|
|
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
|
if salesperson:
|
|
order_vals["user_id"] = salesperson.id
|
|
_logger.info(
|
|
"Creating sale.order with salesperson %s (%d)",
|
|
salesperson.name,
|
|
salesperson.id,
|
|
)
|
|
|
|
# Create order with sudo to avoid permission issues with portal users
|
|
sale_order = request.env["sale.order"].sudo().create(order_vals)
|
|
_logger.info(
|
|
"sale.order created successfully: %d with group_order_id=%d, pickup_day=%s, home_delivery=%s, consumer_group_id=%s",
|
|
sale_order.id,
|
|
group_order.id,
|
|
group_order.pickup_day,
|
|
effective_home_delivery,
|
|
consumer_group_id,
|
|
)
|
|
return sale_order
|
|
|
|
def _create_draft_sale_order(
|
|
self,
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
order_id,
|
|
pickup_date=None,
|
|
is_delivery=False,
|
|
):
|
|
"""Create a draft sale.order from prepared lines and propagate group fields.
|
|
|
|
Returns created sale.order record.
|
|
"""
|
|
consumer_group_id = self._validate_user_group_access(group_order, current_user)
|
|
|
|
_logger.info(
|
|
"[CONSUMER_GROUP DEBUG] _create_draft_sale_order: "
|
|
"group_order=%s, group_order.group_ids=%s, consumer_group_id=%s",
|
|
group_order.id,
|
|
group_order.group_ids.ids,
|
|
consumer_group_id,
|
|
)
|
|
|
|
effective_home_delivery, commitment_date = self._get_effective_delivery_context(
|
|
group_order, is_delivery
|
|
)
|
|
|
|
order_vals = {
|
|
"partner_id": current_user.partner_id.id,
|
|
"order_line": sale_order_lines,
|
|
"state": "draft",
|
|
"group_order_id": order_id,
|
|
"pickup_day": group_order.pickup_day,
|
|
"pickup_date": group_order.pickup_date,
|
|
"home_delivery": effective_home_delivery,
|
|
"consumer_group_id": consumer_group_id,
|
|
"commitment_date": commitment_date,
|
|
}
|
|
|
|
# Get salesperson for order creation (portal users need this)
|
|
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
|
if salesperson:
|
|
order_vals["user_id"] = salesperson.id
|
|
_logger.info(
|
|
"Creating draft sale.order with salesperson %s (%d)",
|
|
salesperson.name,
|
|
salesperson.id,
|
|
)
|
|
|
|
# Create order with sudo to avoid permission issues with portal users
|
|
sale_order = request.env["sale.order"].sudo().create(order_vals)
|
|
|
|
# Ensure the order has a name (sequence)
|
|
try:
|
|
if not sale_order.name or sale_order.name == "New":
|
|
sale_order._onchange_partner_id()
|
|
if not sale_order.name or sale_order.name == "New":
|
|
sale_order.name = "DRAFT-%s" % sale_order.id
|
|
except Exception as exc:
|
|
# Do not break creation on name generation issues
|
|
_logger.warning(
|
|
"Failed to generate name for draft sale order %s: %s",
|
|
sale_order.id,
|
|
exc,
|
|
)
|
|
|
|
return sale_order
|
|
|
|
def _build_confirmation_message(self, sale_order, group_order, is_delivery):
|
|
"""Build localized confirmation message for confirm_eskaera."""
|
|
# Get pickup day index, localized name and date string using helper
|
|
pickup_day_name, pickup_date_str, pickup_day_index = self._format_pickup_info(
|
|
group_order, is_delivery
|
|
)
|
|
|
|
# Initialize translatable strings
|
|
base_message = request.env._("Thank you! Your order has been confirmed.")
|
|
order_reference_label = request.env._("Order reference")
|
|
pickup_label = request.env._("Pickup day")
|
|
delivery_label = request.env._("Delivery date")
|
|
|
|
# Add order reference to message
|
|
if sale_order.name:
|
|
base_message = (
|
|
f"{base_message}\n\n{order_reference_label}: {sale_order.name}"
|
|
)
|
|
|
|
# Build final message with correct label and date based on delivery or pickup
|
|
message = base_message
|
|
label_to_use = delivery_label if is_delivery else pickup_label
|
|
if pickup_day_name and pickup_date_str:
|
|
message = (
|
|
f"{message}\n\n\n{label_to_use}: {pickup_day_name} ({pickup_date_str})"
|
|
)
|
|
elif pickup_day_name:
|
|
message = f"{message}\n\n\n{label_to_use}: {pickup_day_name}"
|
|
elif pickup_date_str:
|
|
message = f"{message}\n\n\n{label_to_use}: {pickup_date_str}"
|
|
|
|
# Log for translation debugging
|
|
try:
|
|
_logger.info(
|
|
"_build_confirmation_message: lang=%s, message=%s",
|
|
request.env.lang,
|
|
message,
|
|
)
|
|
except Exception:
|
|
_logger.info("_build_confirmation_message: message logging failed")
|
|
|
|
return {
|
|
"message": message,
|
|
"pickup_day": pickup_day_name,
|
|
"pickup_date": pickup_date_str,
|
|
"pickup_day_index": pickup_day_index,
|
|
}
|
|
|
|
def _format_pickup_info(self, group_order, is_delivery):
|
|
"""Return (pickup_day_name, pickup_date_str, pickup_day_index) localized.
|
|
|
|
Encapsulates day name detection and date formatting to reduce method complexity.
|
|
"""
|
|
# Get pickup day index
|
|
try:
|
|
pickup_day_index = int(group_order.pickup_day)
|
|
except Exception:
|
|
pickup_day_index = None
|
|
|
|
pickup_day_name = ""
|
|
pickup_date_str = ""
|
|
|
|
# Get translated day names
|
|
if pickup_day_index is not None:
|
|
try:
|
|
day_names = self._get_day_names(env=request.env)
|
|
pickup_day_name = day_names[pickup_day_index % len(day_names)]
|
|
except Exception:
|
|
pickup_day_name = ""
|
|
|
|
# Add pickup/delivery date in numeric format
|
|
if group_order.pickup_date:
|
|
if is_delivery:
|
|
# For delivery, use delivery_date (already computed as pickup_date + 1)
|
|
if group_order.delivery_date:
|
|
pickup_date_str = group_order.delivery_date.strftime("%d/%m/%Y")
|
|
# For delivery, use the next day's name
|
|
if pickup_day_index is not None:
|
|
try:
|
|
day_names = self._get_day_names(env=request.env)
|
|
# Get the next day's name for delivery
|
|
next_day_index = (pickup_day_index + 1) % 7
|
|
pickup_day_name = day_names[next_day_index]
|
|
except Exception:
|
|
pickup_day_name = ""
|
|
else:
|
|
pickup_date_str = group_order.pickup_date.strftime("%d/%m/%Y")
|
|
else:
|
|
# For pickup, use the same date
|
|
pickup_date_str = group_order.pickup_date.strftime("%d/%m/%Y")
|
|
|
|
return pickup_day_name, pickup_date_str, pickup_day_index
|
|
|
|
@http.route(["/eskaera"], type="http", auth="user", website=True)
|
|
def eskaera_list(self, **post):
|
|
"""Página de pedidos de grupo abiertos esta semana.
|
|
|
|
Para usuarios portal: solo muestra pedidos de sus grupos de consumo asignados.
|
|
Para usuarios internos: muestra todos los pedidos de la compañía.
|
|
"""
|
|
group_order_obj = request.env["group.order"].sudo()
|
|
current_user = request.env.user
|
|
|
|
# Validate that the user has a partner_id
|
|
if not current_user.partner_id:
|
|
_logger.error("eskaera_list: User %d has no partner_id", current_user.id)
|
|
return request.redirect("/web")
|
|
|
|
# Obtener pedidos activos para esta semana (filtrados por company_id)
|
|
active_orders = group_order_obj.get_active_orders_for_week()
|
|
|
|
# Para usuarios portal: filtrar solo por sus grupos de consumo
|
|
if current_user.share:
|
|
consumer_group_ids = current_user.partner_id.group_ids.ids
|
|
active_orders = active_orders.filtered(
|
|
lambda o: any(g.id in consumer_group_ids for g in o.group_ids)
|
|
)
|
|
|
|
_logger.info("=== ESKAERA LIST ===")
|
|
_logger.info("User: %s (ID: %d)", current_user.name, current_user.id)
|
|
_logger.info("User company: %s", current_user.company_id.name)
|
|
_logger.info(
|
|
"Active orders from get_active_orders_for_week: %s",
|
|
active_orders.mapped("name"),
|
|
)
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_page",
|
|
{
|
|
"active_orders": active_orders,
|
|
"day_names": self._get_day_names(env=request.env),
|
|
},
|
|
)
|
|
|
|
def _filter_published_tags(self, tags):
|
|
"""Filter tags to only include those visible on ecommerce."""
|
|
return tags.filtered(lambda t: getattr(t, "visible_on_ecommerce", True))
|
|
|
|
def _collect_all_products_and_categories(self, group_order):
|
|
"""Collect all products for the group_order and build available categories and hierarchy.
|
|
|
|
Returns: (all_products, available_categories, category_hierarchy)
|
|
"""
|
|
all_products = group_order._get_products_for_group_order(group_order.id)
|
|
|
|
product_categories = all_products.mapped("categ_id").filtered(
|
|
lambda c: c.id > 0
|
|
)
|
|
all_categories_set = set()
|
|
|
|
def collect_category_and_parents(category):
|
|
if category and category.id > 0:
|
|
all_categories_set.add(category.id)
|
|
if category.parent_id:
|
|
collect_category_and_parents(category.parent_id)
|
|
|
|
for cat in product_categories:
|
|
collect_category_and_parents(cat)
|
|
|
|
available_categories = (
|
|
request.env["product.category"].sudo().browse(list(all_categories_set))
|
|
)
|
|
available_categories = sorted(set(available_categories), key=lambda c: c.name)
|
|
|
|
category_hierarchy = self._build_category_hierarchy(available_categories)
|
|
return all_products, available_categories, category_hierarchy
|
|
|
|
def _prepare_products_maps(self, products, pricelist):
|
|
"""Compute price, supplier and display maps for a list of products.
|
|
|
|
Returns: (product_price_info, product_supplier_info, product_display_info, filtered_products_dict)
|
|
"""
|
|
product_price_info = self._compute_price_info(products, pricelist)
|
|
product_supplier_info = self._get_product_supplier_info(products)
|
|
|
|
product_display_info = {}
|
|
filtered_products_dict = {}
|
|
for product in products:
|
|
product_display_info[product.id] = self._prepare_product_display_info(
|
|
product, product_price_info
|
|
)
|
|
filtered_products_dict[product.id] = {
|
|
"product": product,
|
|
"published_tags": self._filter_published_tags(product.product_tag_ids),
|
|
}
|
|
|
|
return (
|
|
product_price_info,
|
|
product_supplier_info,
|
|
product_display_info,
|
|
filtered_products_dict,
|
|
)
|
|
|
|
def _merge_or_replace_draft(
|
|
self,
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
existing_drafts,
|
|
order_id,
|
|
is_delivery=False,
|
|
):
|
|
"""Replace existing draft (if any) with new lines, else create it.
|
|
|
|
All fields (commitment_date, consumer_group_id, etc.) come from group_order.
|
|
"""
|
|
consumer_group_id = self._validate_user_group_access(group_order, current_user)
|
|
|
|
_logger.info(
|
|
"[CONSUMER_GROUP DEBUG] _merge_or_replace_draft: "
|
|
"group_order=%s, group_order.group_ids=%s, consumer_group_id=%s",
|
|
group_order.id,
|
|
group_order.group_ids.ids,
|
|
consumer_group_id,
|
|
)
|
|
|
|
effective_home_delivery, commitment_date = self._get_effective_delivery_context(
|
|
group_order, is_delivery
|
|
)
|
|
|
|
if existing_drafts:
|
|
draft = existing_drafts[0].sudo()
|
|
_logger.info(
|
|
"Replacing existing draft order %s for partner %s",
|
|
draft.id,
|
|
current_user.partner_id.id,
|
|
)
|
|
draft.write(
|
|
{
|
|
"order_line": [(5, 0, 0)] + sale_order_lines,
|
|
"group_order_id": order_id,
|
|
"pickup_day": group_order.pickup_day,
|
|
"pickup_date": group_order.pickup_date,
|
|
"home_delivery": effective_home_delivery,
|
|
"consumer_group_id": consumer_group_id,
|
|
"commitment_date": commitment_date,
|
|
}
|
|
)
|
|
return draft
|
|
|
|
order_vals = {
|
|
"partner_id": current_user.partner_id.id,
|
|
"order_line": sale_order_lines,
|
|
"state": "draft",
|
|
"group_order_id": order_id,
|
|
"pickup_day": group_order.pickup_day,
|
|
"pickup_date": group_order.pickup_date,
|
|
"home_delivery": effective_home_delivery,
|
|
"consumer_group_id": consumer_group_id,
|
|
"commitment_date": commitment_date,
|
|
}
|
|
# Get salesperson for order creation (portal users need this)
|
|
salesperson = self._get_salesperson_for_order(current_user.partner_id)
|
|
if salesperson:
|
|
order_vals["user_id"] = salesperson.id
|
|
|
|
sale_order = request.env["sale.order"].sudo().create(order_vals)
|
|
return sale_order
|
|
|
|
def _decode_json_body(self):
|
|
"""Safely decode JSON body from request. Returns dict or raises ValueError."""
|
|
if not request.httprequest.data:
|
|
raise ValueError("No data provided")
|
|
raw_data = request.httprequest.data
|
|
if isinstance(raw_data, bytes):
|
|
raw_data = raw_data.decode("utf-8")
|
|
try:
|
|
data = json.loads(raw_data)
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid JSON: {str(e)}") from e
|
|
return data
|
|
|
|
def _build_group_order_unavailable_response(self, group_order, status=403):
|
|
"""Build a consistent JSON response when a group order is not open."""
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"error": request.env._("Order is not available"),
|
|
"code": "group_order_not_open",
|
|
"order_state": group_order.state if group_order else False,
|
|
"action": "clear_cart",
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
status=status,
|
|
)
|
|
|
|
def _find_recent_draft_order(self, partner_id, group_order):
|
|
"""Find most recent draft sale.order for partner in the active order period.
|
|
|
|
Priority for period matching:
|
|
1) If group_order.pickup_date is set, match that exact pickup_date
|
|
(prevents reusing stale drafts from previous cycles of same group.order).
|
|
2) Fallback to current-week create_date bounds when pickup_date is not set.
|
|
|
|
Returns the recordset (limit=1) or empty recordset.
|
|
"""
|
|
today = datetime.now().date()
|
|
start_of_week = today - timedelta(days=today.weekday())
|
|
end_of_week = start_of_week + timedelta(days=6)
|
|
|
|
domain = [
|
|
("partner_id", "=", partner_id),
|
|
("group_order_id", "=", group_order.id),
|
|
("state", "=", "draft"),
|
|
]
|
|
|
|
if group_order.pickup_date:
|
|
domain.append(("pickup_date", "=", group_order.pickup_date))
|
|
else:
|
|
domain.extend(
|
|
[
|
|
("create_date", ">=", f"{start_of_week} 00:00:00"),
|
|
("create_date", "<=", f"{end_of_week} 23:59:59"),
|
|
]
|
|
)
|
|
|
|
return (
|
|
request.env["sale.order"]
|
|
.sudo()
|
|
.search(domain, order="create_date desc", limit=1)
|
|
)
|
|
|
|
@http.route(["/eskaera/<int:order_id>"], type="http", auth="user", website=True)
|
|
def eskaera_shop(self, order_id, **post):
|
|
"""Página de tienda para un pedido específico (eskaera).
|
|
|
|
Muestra productos del pedido y gestiona el carrito separado.
|
|
Soporta búsqueda y filtrado por categoría.
|
|
"""
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
|
|
if not group_order.exists():
|
|
return request.redirect("/eskaera")
|
|
|
|
# Verificar que el pedido está activo
|
|
if group_order.state != "open":
|
|
return request.redirect("/eskaera")
|
|
|
|
# Seguridad: para usuarios portal, verificar que pertenecen a un grupo de consumo
|
|
# que tiene acceso a este pedido
|
|
current_user = request.env.user
|
|
if current_user.share:
|
|
consumer_group_ids = current_user.partner_id.group_ids.ids
|
|
order_group_ids = group_order.group_ids.ids
|
|
if not any(gid in consumer_group_ids for gid in order_group_ids):
|
|
return request.redirect("/eskaera")
|
|
|
|
# Print order cutoff date information
|
|
_logger.info("=== ESKAERA SHOP ===")
|
|
_logger.info("Order: %s (ID: %d)", group_order.name, group_order.id)
|
|
_logger.info("Cutoff Day: %s (0=Monday, 6=Sunday)", group_order.cutoff_day)
|
|
_logger.info("Pickup Day: %s", group_order.pickup_day)
|
|
if group_order.start_date:
|
|
_logger.info("Start Date: %s", group_order.start_date.strftime("%Y-%m-%d"))
|
|
if group_order.end_date:
|
|
_logger.info("End Date: %s", group_order.end_date.strftime("%Y-%m-%d"))
|
|
|
|
# Get lazy loading configuration
|
|
lazy_loading_enabled = (
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.lazy_loading_enabled", "True")
|
|
== "True"
|
|
)
|
|
per_page = int(
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.products_per_page", 20)
|
|
)
|
|
|
|
# Get page parameter (default to 1)
|
|
try:
|
|
page = int(post.get("page", 1))
|
|
if page < 1:
|
|
page = 1
|
|
except (ValueError, TypeError):
|
|
page = 1
|
|
|
|
_logger.info(
|
|
"eskaera_shop: lazy_loading=%s, per_page=%d, page=%d",
|
|
lazy_loading_enabled,
|
|
per_page,
|
|
page,
|
|
)
|
|
|
|
# Collect all products and categories and build hierarchy using helper
|
|
all_products, available_categories, category_hierarchy = (
|
|
self._collect_all_products_and_categories(group_order)
|
|
)
|
|
_logger.info(
|
|
"eskaera_shop order_id=%d, total products=%d (discovered)",
|
|
order_id,
|
|
len(all_products),
|
|
)
|
|
|
|
# Apply search/category filters and compute available tags
|
|
filtered_products, available_tags, search_query, category_filter = (
|
|
self._filter_products(all_products, post, group_order)
|
|
)
|
|
|
|
# Pagination
|
|
total_products = len(filtered_products)
|
|
has_next = False
|
|
products = filtered_products
|
|
if lazy_loading_enabled:
|
|
offset = (page - 1) * per_page
|
|
products = filtered_products[offset : offset + per_page]
|
|
has_next = offset + per_page < total_products
|
|
_logger.info(
|
|
"eskaera_shop: Paginated - page=%d, offset=%d, per_page=%d, has_next=%s, showing %d of %d",
|
|
page,
|
|
offset,
|
|
per_page,
|
|
has_next,
|
|
len(products),
|
|
total_products,
|
|
)
|
|
|
|
# Compute pricing and prepare maps
|
|
_logger.info("eskaera_shop: Starting price calculation for order %d", order_id)
|
|
pricelist = self._resolve_pricelist()
|
|
(
|
|
product_price_info,
|
|
product_supplier_info,
|
|
product_display_info,
|
|
filtered_products_dict,
|
|
) = self._prepare_products_maps(products, pricelist)
|
|
|
|
# Manage session for separate cart per order
|
|
session_key = f"eskaera_{order_id}"
|
|
cart = request.session.get(session_key, {})
|
|
|
|
# Get delivery product from group_order (configured per group order)
|
|
delivery_product = group_order.delivery_product_id
|
|
delivery_product_id = delivery_product.id if delivery_product else None
|
|
# Get translated product name based on current language
|
|
if delivery_product:
|
|
delivery_product_translated = delivery_product.with_context(
|
|
lang=request.env.lang
|
|
)
|
|
delivery_product_name = delivery_product_translated.name
|
|
else:
|
|
delivery_product_name = "Home Delivery"
|
|
|
|
# Get translated labels for JavaScript (same as checkout)
|
|
labels = self.get_checkout_labels()
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_shop",
|
|
{
|
|
"group_order": group_order,
|
|
"products": products,
|
|
"filtered_product_tags": filtered_products_dict,
|
|
"cart": cart,
|
|
"available_categories": available_categories,
|
|
"category_hierarchy": category_hierarchy,
|
|
"available_tags": available_tags,
|
|
"search_query": search_query,
|
|
"selected_category": category_filter,
|
|
"day_names": self._get_day_names(env=request.env),
|
|
"product_supplier_info": product_supplier_info,
|
|
"product_price_info": product_price_info,
|
|
"product_display_info": product_display_info,
|
|
"delivery_product_id": delivery_product_id,
|
|
"delivery_product_name": delivery_product_name,
|
|
"delivery_product_price": self._get_delivery_product_display_price(
|
|
delivery_product, pricelist=pricelist
|
|
),
|
|
"labels": labels,
|
|
"labels_json": json.dumps(labels, ensure_ascii=False),
|
|
"lazy_loading_enabled": lazy_loading_enabled,
|
|
"per_page": per_page,
|
|
"current_page": page,
|
|
"has_next": has_next,
|
|
"total_products": total_products,
|
|
},
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:order_id>/load-page"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["GET"],
|
|
)
|
|
def load_eskaera_page(self, order_id, **post):
|
|
"""Load next page of products for lazy loading.
|
|
|
|
Respects same search/filter parameters as eskaera_shop.
|
|
Returns only HTML of product cards without page wrapper.
|
|
"""
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
|
|
if not group_order.exists() or group_order.state != "open":
|
|
return ""
|
|
|
|
# Get lazy loading configuration
|
|
per_page = int(
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.products_per_page", 20)
|
|
)
|
|
|
|
# Get page parameter
|
|
try:
|
|
page = int(post.get("page", 1))
|
|
if page < 1:
|
|
page = 1
|
|
except (ValueError, TypeError):
|
|
page = 1
|
|
|
|
_logger.info(
|
|
"load_eskaera_page: order_id=%d, page=%d, per_page=%d",
|
|
order_id,
|
|
page,
|
|
per_page,
|
|
)
|
|
|
|
# Get all products and apply standard filters using shared helper
|
|
all_products = group_order._get_products_for_group_order(group_order.id)
|
|
|
|
# Get search and filter parameters (passed via POST/GET)
|
|
search_query = post.get("search", "").strip()
|
|
category_filter = post.get("category", "0")
|
|
|
|
filtered_products, available_tags, search_query, category_filter = (
|
|
self._filter_products(
|
|
all_products,
|
|
{"search": search_query, "category": category_filter},
|
|
group_order,
|
|
)
|
|
)
|
|
|
|
# ===== Apply pagination to the FILTERED results using shared logic =====
|
|
total_products = len(filtered_products)
|
|
offset = (page - 1) * per_page
|
|
products_page = filtered_products[offset : offset + per_page]
|
|
has_next = offset + per_page < total_products
|
|
|
|
_logger.info(
|
|
"load_eskaera_page: page=%d, offset=%d, showing %d of %d filtered",
|
|
page,
|
|
offset,
|
|
len(products_page),
|
|
total_products,
|
|
)
|
|
|
|
# Get pricelist and compute prices using shared helper
|
|
pricelist = self._resolve_pricelist()
|
|
product_price_info = self._compute_price_info(products_page, pricelist)
|
|
|
|
# Prepare supplier info and display maps using shared helpers
|
|
product_supplier_info = self._get_product_supplier_info(products_page)
|
|
|
|
filtered_products_dict = {}
|
|
for product in products_page:
|
|
published_tags = self._filter_published_tags(product.product_tag_ids)
|
|
filtered_products_dict[product.id] = {
|
|
"product": product,
|
|
"published_tags": published_tags,
|
|
}
|
|
|
|
product_display_info = {}
|
|
for product in products_page:
|
|
product_display_info[product.id] = self._prepare_product_display_info(
|
|
product, product_price_info
|
|
)
|
|
|
|
labels = self.get_checkout_labels()
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_shop_products",
|
|
{
|
|
"group_order": group_order,
|
|
"products": products_page,
|
|
"filtered_product_tags": filtered_products_dict,
|
|
"product_supplier_info": product_supplier_info,
|
|
"product_price_info": product_price_info,
|
|
"product_display_info": product_display_info,
|
|
"labels": labels,
|
|
"has_next": has_next,
|
|
"next_page": page + 1,
|
|
},
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:order_id>/load-products-ajax"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def load_products_ajax(self, order_id, **post):
|
|
"""Load products via AJAX for infinite scroll.
|
|
|
|
Returns JSON with:
|
|
- html: rendered product cards HTML
|
|
- has_next: whether there are more products
|
|
- next_page: page number to fetch next
|
|
- total: total filtered products
|
|
"""
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
|
|
if not group_order.exists() or group_order.state != "open":
|
|
return {"error": "Order not found or not open", "html": ""}
|
|
|
|
# Get configuration
|
|
per_page = int(
|
|
request.env["ir.config_parameter"]
|
|
.sudo()
|
|
.get_param("website_sale_aplicoop.products_per_page", 20)
|
|
)
|
|
|
|
# Parse JSON body for parameters (type="http" doesn't auto-parse JSON)
|
|
params = {}
|
|
try:
|
|
if request.httprequest.content_length:
|
|
data = request.httprequest.get_data(as_text=True)
|
|
if data:
|
|
params = json.loads(data)
|
|
except (ValueError, json.JSONDecodeError, AttributeError):
|
|
params = {}
|
|
|
|
# Get page from POST/JSON
|
|
try:
|
|
page = int(params.get("page", post.get("page", 1)))
|
|
if page < 1:
|
|
page = 1
|
|
except (ValueError, TypeError):
|
|
page = 1
|
|
|
|
# Get filters
|
|
search_query = params.get("search", post.get("search", "")).strip()
|
|
category_filter = str(params.get("category", post.get("category", "0")))
|
|
|
|
_logger.info(
|
|
"load_products_ajax: order_id=%d, page=%d, search=%s, category=%s",
|
|
order_id,
|
|
page,
|
|
search_query,
|
|
category_filter,
|
|
)
|
|
|
|
# Get all products and apply shared filtering logic
|
|
all_products = group_order._get_products_for_group_order(group_order.id)
|
|
filtered_products, available_tags, _, _ = self._filter_products(
|
|
all_products,
|
|
{"search": search_query, "category": category_filter},
|
|
group_order,
|
|
)
|
|
|
|
# Paginate
|
|
total_products = len(filtered_products)
|
|
offset = (page - 1) * per_page
|
|
products_page = filtered_products[offset : offset + per_page]
|
|
has_next = offset + per_page < total_products
|
|
|
|
_logger.info(
|
|
"load_products_ajax: Pagination - page=%d, offset=%d, per_page=%d, total=%d, has_next=%s",
|
|
page,
|
|
offset,
|
|
per_page,
|
|
total_products,
|
|
has_next,
|
|
)
|
|
|
|
# Compute prices and supplier/display info using shared helpers
|
|
pricelist = self._resolve_pricelist()
|
|
product_price_info = self._compute_price_info(products_page, pricelist)
|
|
product_display_info = {
|
|
product.id: self._prepare_product_display_info(product, product_price_info)
|
|
for product in products_page
|
|
}
|
|
product_supplier_info = self._get_product_supplier_info(products_page)
|
|
|
|
filtered_products_dict = {
|
|
product.id: {
|
|
"product": product,
|
|
"published_tags": self._filter_published_tags(product.product_tag_ids),
|
|
}
|
|
for product in products_page
|
|
}
|
|
|
|
# Render HTML
|
|
html = (
|
|
request.env["ir.ui.view"]
|
|
.sudo()
|
|
._render_template(
|
|
"website_sale_aplicoop.eskaera_shop_products",
|
|
{
|
|
"group_order": group_order,
|
|
"products": products_page,
|
|
"filtered_product_tags": filtered_products_dict,
|
|
"product_supplier_info": product_supplier_info,
|
|
"product_price_info": product_price_info,
|
|
"product_display_info": product_display_info,
|
|
"labels": self.get_checkout_labels(),
|
|
"has_next": has_next,
|
|
"next_page": page + 1,
|
|
},
|
|
)
|
|
)
|
|
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"html": html,
|
|
"has_next": has_next,
|
|
"next_page": page + 1,
|
|
"total": total_products,
|
|
"page": page,
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/add-to-cart"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def add_to_eskaera_cart(self, **post):
|
|
"""Validate and confirm product addition to cart.
|
|
|
|
The cart is managed in localStorage on the frontend.
|
|
This endpoint only validates that the product exists in the order.
|
|
"""
|
|
import json
|
|
|
|
try:
|
|
# Get JSON data from the request body
|
|
data = (
|
|
json.loads(request.httprequest.data) if request.httprequest.data else {}
|
|
)
|
|
|
|
order_id = int(data.get("order_id", 0))
|
|
product_id = int(data.get("product_id", 0))
|
|
quantity = float(data.get("quantity", 1))
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
product = request.env["product.product"].sudo().browse(product_id)
|
|
|
|
# Validate that the order exists and is open
|
|
if not group_order.exists() or group_order.state != "open":
|
|
_logger.warning(
|
|
"add_to_eskaera_cart: Order %d not available (exists=%s, state=%s)",
|
|
order_id,
|
|
group_order.exists(),
|
|
group_order.state if group_order.exists() else "N/A",
|
|
)
|
|
return request.make_response(
|
|
json.dumps({"error": "Order is not available"}),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
# Validate that the product is available in this order (use discovery logic)
|
|
available_products = group_order._get_products_for_group_order(
|
|
group_order.id
|
|
)
|
|
if product not in available_products:
|
|
_logger.warning(
|
|
"add_to_eskaera_cart: Product %d not available in order %d",
|
|
product_id,
|
|
order_id,
|
|
)
|
|
return request.make_response(
|
|
json.dumps({"error": "Product not available in this order"}),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
# Validate quantity
|
|
if quantity <= 0:
|
|
return request.make_response(
|
|
json.dumps({"error": "Quantity must be greater than 0"}),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
_logger.info(
|
|
"add_to_eskaera_cart: Added product %d (qty=%f) to order %d",
|
|
product_id,
|
|
quantity,
|
|
order_id,
|
|
)
|
|
|
|
# Get price with taxes using pricelist
|
|
_logger.info(
|
|
"add_to_eskaera_cart: Getting price for product %s (id=%s)",
|
|
product.name,
|
|
product_id,
|
|
)
|
|
pricelist = None
|
|
|
|
# Resolve pricelist using centralized helper
|
|
pricelist = self._resolve_pricelist()
|
|
|
|
if not pricelist:
|
|
_logger.error(
|
|
"add_to_eskaera_cart: ERROR - No pricelist found! Using list_price for product %s",
|
|
product.name,
|
|
)
|
|
|
|
product_variant = (
|
|
product.product_variant_ids[0] if product.product_variant_ids else False
|
|
)
|
|
|
|
if product_variant and pricelist:
|
|
try:
|
|
# Use OCA _get_price method - more robust and complete
|
|
price_info = product_variant._get_price(
|
|
qty=quantity,
|
|
pricelist=pricelist,
|
|
fposition=request.website.fiscal_position_id,
|
|
)
|
|
price_with_tax = price_info.get("value", product.list_price)
|
|
_logger.info(
|
|
"add_to_eskaera_cart: Product %s - Price: %.2f (original: %.2f, discount: %.1f%%)",
|
|
product.name,
|
|
price_with_tax,
|
|
price_info.get("original_value", 0),
|
|
price_info.get("discount", 0),
|
|
)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"add_to_eskaera_cart: Error getting price for product %s: %s. Using list_price=%.2f",
|
|
product.name,
|
|
str(e),
|
|
product.list_price,
|
|
)
|
|
else:
|
|
reason = "no pricelist" if not pricelist else "no variant"
|
|
_logger.info(
|
|
"add_to_eskaera_cart: Product %s - Using list_price fallback (reason: %s). Price=%.2f",
|
|
product.name,
|
|
reason,
|
|
price_with_tax,
|
|
)
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"message": request.env._("%s added to cart", product.name),
|
|
"product_id": product_id,
|
|
"quantity": quantity,
|
|
"price": price_with_tax,
|
|
}
|
|
return request.make_response(
|
|
json.dumps(response_data), [("Content-Type", "application/json")]
|
|
)
|
|
|
|
except ValueError as e:
|
|
_logger.error("add_to_eskaera_cart: ValueError: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid parameters: {str(e)}"}),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
except Exception as e:
|
|
_logger.error("add_to_eskaera_cart: Exception: %s", str(e), exc_info=True)
|
|
return request.make_response(json.dumps({"error": f"Error: {str(e)}"}))
|
|
|
|
@http.route(
|
|
["/eskaera/<int:order_id>/checkout"], type="http", auth="user", website=True
|
|
)
|
|
def eskaera_checkout(self, order_id, **post):
|
|
"""Checkout page to close the cart for the order (eskaera)."""
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
|
|
if not group_order.exists():
|
|
return request.redirect("/eskaera")
|
|
|
|
# Verificar que el pedido está activo
|
|
if group_order.state != "open":
|
|
return request.redirect("/eskaera")
|
|
|
|
# Los datos del carrito vienen desde localStorage en el frontend
|
|
# Esta página solo muestra resumen y botón de confirmación
|
|
|
|
# DEBUG: Log ALL delivery fields
|
|
_logger.warning("=== ESKAERA_CHECKOUT DELIVERY DEBUG ===")
|
|
_logger.warning("group_order.id: %s", group_order.id)
|
|
_logger.warning("group_order.name: %s", group_order.name)
|
|
_logger.warning(
|
|
"group_order.pickup_day: %s (type: %s)",
|
|
group_order.pickup_day,
|
|
type(group_order.pickup_day),
|
|
)
|
|
_logger.warning(
|
|
"group_order.pickup_date: %s (type: %s)",
|
|
group_order.pickup_date,
|
|
type(group_order.pickup_date),
|
|
)
|
|
_logger.warning(
|
|
"group_order.delivery_date: %s (type: %s)",
|
|
group_order.delivery_date,
|
|
type(group_order.delivery_date),
|
|
)
|
|
_logger.warning("group_order.home_delivery: %s", group_order.home_delivery)
|
|
_logger.warning("group_order.delivery_notice: %s", group_order.delivery_notice)
|
|
if group_order.pickup_date:
|
|
_logger.warning(
|
|
"pickup_date formatted: %s",
|
|
group_order.pickup_date.strftime("%d/%m/%Y"),
|
|
)
|
|
_logger.warning("========================================")
|
|
|
|
# Get delivery product from group_order (configured per group order)
|
|
delivery_product = group_order.delivery_product_id
|
|
delivery_product_id = delivery_product.id if delivery_product else None
|
|
# Get translated product name based on current language
|
|
if delivery_product:
|
|
delivery_product_translated = delivery_product.with_context(
|
|
lang=request.env.lang
|
|
)
|
|
delivery_product_name = delivery_product_translated.name
|
|
else:
|
|
delivery_product_name = "Home Delivery"
|
|
|
|
# Get all translated labels for JavaScript (same as shop page)
|
|
# This includes all 37 labels: modal labels, confirmation, notifications, cart buttons, etc.
|
|
labels = self.get_checkout_labels()
|
|
|
|
# Convert to JSON string for safe embedding in script tag
|
|
labels_json = json.dumps(labels, ensure_ascii=False)
|
|
|
|
# Prepare template context with explicit debug info
|
|
template_context = {
|
|
"group_order": group_order,
|
|
"day_names": self._get_day_names(env=request.env),
|
|
"delivery_product_id": delivery_product_id,
|
|
"delivery_product_name": delivery_product_name, # Auto-translated to user's language
|
|
"delivery_product_price": self._get_delivery_product_display_price(
|
|
delivery_product
|
|
),
|
|
"labels": labels,
|
|
"labels_json": labels_json,
|
|
}
|
|
|
|
_logger.warning("Template context keys: %s", list(template_context.keys()))
|
|
|
|
return request.render(
|
|
"website_sale_aplicoop.eskaera_checkout", template_context
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/check-status"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def check_group_order_status(self, **post):
|
|
"""Return status information for a group.order.
|
|
|
|
Intended for frontend proactive cart invalidation when the order
|
|
changed state between visits.
|
|
"""
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
order_id = int(order_id)
|
|
except (TypeError, ValueError):
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=404,
|
|
)
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"order_id": group_order.id,
|
|
"state": group_order.state,
|
|
"is_open": group_order.state == "open",
|
|
"action": "clear_cart" if group_order.state != "open" else "none",
|
|
}
|
|
return request.make_response(
|
|
json.dumps(response_data),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/save-cart"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def save_cart_draft(self, **post):
|
|
"""Save cart items as a draft sale.order with pickup date."""
|
|
try:
|
|
_logger.warning("=== SAVE_CART_DRAFT CALLED ===")
|
|
|
|
# Decode JSON body using helper
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
_logger.info("save_cart_draft data received: %s", data)
|
|
|
|
# Validate order and basic user requirements
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError):
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
if group_order.state != "open":
|
|
return self._build_group_order_unavailable_response(group_order)
|
|
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "User has no associated partner"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
self._validate_user_group_access(group_order, current_user)
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=403,
|
|
)
|
|
|
|
items = data.get("items", [])
|
|
pickup_date = data.get("pickup_date")
|
|
is_delivery = self._to_bool(data.get("is_delivery", False))
|
|
if not items:
|
|
return request.make_response(
|
|
json.dumps({"error": "No items in cart"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Build sale.order lines and create draft using helpers
|
|
try:
|
|
sale_order_lines = self._process_cart_items(
|
|
items, group_order, pricelist=self._resolve_pricelist()
|
|
)
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
sale_order = self._create_draft_sale_order(
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
order_id,
|
|
pickup_date,
|
|
is_delivery=is_delivery,
|
|
)
|
|
|
|
_logger.info(
|
|
"Draft sale.order created: %d (name: %s) for partner %d",
|
|
sale_order.id,
|
|
sale_order.name,
|
|
current_user.partner_id.id,
|
|
)
|
|
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"success": True,
|
|
"message": request.env._("Cart saved as draft"),
|
|
"sale_order_id": sale_order.id,
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
_logger.error("save_cart_draft: Unexpected error: %s", str(e))
|
|
_logger.error(traceback.format_exc())
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=500,
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/load-draft"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def load_draft_cart(self, **post):
|
|
"""Load items from the most recent draft sale.order for current period."""
|
|
import json
|
|
|
|
try:
|
|
_logger.warning("=== LOAD_DRAFT_CART CALLED ===")
|
|
|
|
if not request.httprequest.data:
|
|
return request.make_response(
|
|
json.dumps({"error": "No data provided"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Decode JSON body
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
_logger.error("Error decoding JSON: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError):
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
if group_order.state != "open":
|
|
return self._build_group_order_unavailable_response(group_order)
|
|
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "User has no associated partner"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Find the most recent draft sale.order for this partner in active period
|
|
# The helper _find_recent_draft_order computes the period criteria itself,
|
|
# so we only need to call it here.
|
|
|
|
# Find the most recent matching draft order using helper
|
|
draft_orders = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
if not draft_orders:
|
|
error_msg = request.env._(
|
|
"No draft orders found for the current order period"
|
|
)
|
|
return request.make_response(
|
|
json.dumps({"error": error_msg}),
|
|
[("Content-Type", "application/json")],
|
|
status=404,
|
|
)
|
|
draft_order = draft_orders[0]
|
|
|
|
# Extract items from the draft order
|
|
items = []
|
|
pricelist = self._resolve_pricelist()
|
|
partner = current_user.partner_id
|
|
for line in draft_order.order_line:
|
|
pricing = self._get_pricing_info(
|
|
line.product_id,
|
|
pricelist,
|
|
quantity=line.product_uom_qty,
|
|
partner=partner,
|
|
)
|
|
items.append(
|
|
{
|
|
"product_id": line.product_id.id,
|
|
"product_name": line.product_id.name,
|
|
"quantity": line.product_uom_qty,
|
|
"product_price": pricing.get("price", line.price_unit),
|
|
}
|
|
)
|
|
|
|
_logger.info(
|
|
"Loaded %d items from draft order %d", len(items), draft_order.id
|
|
)
|
|
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"success": True,
|
|
"message": request.env._("Draft order loaded"),
|
|
"items": items,
|
|
"sale_order_id": draft_order.id,
|
|
"group_order_id": draft_order.group_order_id.id,
|
|
"group_order_name": draft_order.group_order_id.name,
|
|
"pickup_day": draft_order.pickup_day,
|
|
"pickup_date": (
|
|
str(draft_order.pickup_date)
|
|
if draft_order.pickup_date
|
|
else None
|
|
),
|
|
"home_delivery": draft_order.home_delivery,
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
_logger.error("load_draft_cart: Unexpected error: %s", str(e))
|
|
_logger.error(traceback.format_exc())
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=500,
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/clear-cart"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def clear_cart(self, **post):
|
|
"""Clear the user's cart and cancel any existing draft sale.order.
|
|
|
|
Receives: JSON body with 'order_id'
|
|
Returns: JSON with success status and cancelled_order_id if applicable.
|
|
"""
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError):
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=404,
|
|
)
|
|
|
|
current_user = request.env.user
|
|
if not current_user.partner_id:
|
|
return request.make_response(
|
|
json.dumps({"error": "User has no associated partner"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Find and cancel any existing draft sale.order for this period
|
|
cancelled_order_id = None
|
|
try:
|
|
draft_orders = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
if draft_orders:
|
|
draft_order = draft_orders[0]
|
|
cancelled_order_id = draft_order.id
|
|
# Use action_cancel if available, otherwise unlink
|
|
if draft_order.state == "draft":
|
|
draft_order.sudo().action_cancel()
|
|
_logger.info(
|
|
"clear_cart: Cancelled draft sale.order %d for partner %d",
|
|
draft_order.id,
|
|
current_user.partner_id.id,
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"clear_cart: Draft order %d already in state '%s', skipping cancel",
|
|
draft_order.id,
|
|
draft_order.state,
|
|
)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"clear_cart: Error cancelling draft order for partner %d: %s",
|
|
current_user.partner_id.id,
|
|
str(e),
|
|
)
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"message": request.env._("Cart cleared"),
|
|
"cancelled_order_id": cancelled_order_id,
|
|
}
|
|
_logger.info(
|
|
"clear_cart: Cart cleared for partner %d, order %d (cancelled_sale_order: %s)",
|
|
current_user.partner_id.id,
|
|
order_id,
|
|
cancelled_order_id,
|
|
)
|
|
return request.make_response(
|
|
json.dumps(response_data),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/save-order"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def save_eskaera_draft(self, **post):
|
|
"""Save order as draft (without confirming).
|
|
|
|
Creates a sale.order from the cart items with state='draft'.
|
|
If a draft already exists for this group order, prompt user for merge/replace.
|
|
"""
|
|
import json
|
|
|
|
try:
|
|
_logger.warning("=== SAVE_ESKAERA_DRAFT CALLED ===")
|
|
|
|
if not request.httprequest.data:
|
|
_logger.warning("save_eskaera_draft: No request data provided")
|
|
return request.make_response(
|
|
json.dumps({"error": "No data provided"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Decode JSON
|
|
try:
|
|
raw_data = request.httprequest.data
|
|
if isinstance(raw_data, bytes):
|
|
raw_data = raw_data.decode("utf-8")
|
|
data = json.loads(raw_data)
|
|
except Exception as e:
|
|
_logger.error("Error decoding JSON: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid JSON: {str(e)}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
_logger.info("save_eskaera_draft data received: %s", data)
|
|
|
|
# Validate order_id
|
|
order_id = data.get("order_id")
|
|
if not order_id:
|
|
_logger.warning("save_eskaera_draft: order_id missing")
|
|
return request.make_response(
|
|
json.dumps({"error": "order_id is required"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Convert to int
|
|
try:
|
|
order_id = int(order_id)
|
|
except (ValueError, TypeError):
|
|
_logger.warning("save_eskaera_draft: Invalid order_id: %s", order_id)
|
|
return request.make_response(
|
|
json.dumps({"error": f"Invalid order_id format: {order_id}"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Verify that the order exists
|
|
group_order = request.env["group.order"].sudo().browse(order_id)
|
|
if not group_order.exists():
|
|
_logger.warning("save_eskaera_draft: Order %d not found", order_id)
|
|
return request.make_response(
|
|
json.dumps({"error": f"Order {order_id} not found"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
if group_order.state != "open":
|
|
return self._build_group_order_unavailable_response(group_order)
|
|
|
|
current_user = request.env.user
|
|
|
|
# Validate that the user has a partner_id
|
|
if not current_user.partner_id:
|
|
_logger.error(
|
|
"save_eskaera_draft: User %d has no partner_id", current_user.id
|
|
)
|
|
return request.make_response(
|
|
json.dumps({"error": "User has no associated partner"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
self._validate_user_group_access(group_order, current_user)
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=403,
|
|
)
|
|
|
|
# Get cart items
|
|
items = data.get("items", [])
|
|
is_delivery = self._to_bool(data.get("is_delivery", False))
|
|
|
|
if not items:
|
|
_logger.warning(
|
|
"save_eskaera_draft: No items in cart for user %d in order %d",
|
|
current_user.id,
|
|
order_id,
|
|
)
|
|
return request.make_response(
|
|
json.dumps({"error": "No items in cart"}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Check if a draft already exists for this user in current order period
|
|
existing_drafts = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
|
|
_logger.info(
|
|
"Creating draft sale.order with %d items for partner %d",
|
|
len(items),
|
|
current_user.partner_id.id,
|
|
)
|
|
|
|
# Create sales.order lines from items using shared helper
|
|
try:
|
|
sale_order_lines = self._process_cart_items(
|
|
items, group_order, pricelist=self._resolve_pricelist()
|
|
)
|
|
except ValueError as e:
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Delegate merge/replace/create logic to helper
|
|
sale_order = self._merge_or_replace_draft(
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
existing_drafts,
|
|
order_id,
|
|
is_delivery=is_delivery,
|
|
)
|
|
|
|
_logger.info(
|
|
"Draft sale.order created/updated: %d for partner %d with group_order_id=%s, pickup_day=%s, pickup_date=%s, home_delivery=%s",
|
|
sale_order.id,
|
|
current_user.partner_id.id,
|
|
sale_order.group_order_id.id if sale_order.group_order_id else None,
|
|
sale_order.pickup_day,
|
|
sale_order.pickup_date,
|
|
sale_order.home_delivery,
|
|
)
|
|
|
|
return request.make_response(
|
|
json.dumps(
|
|
{
|
|
"success": True,
|
|
"message": request.env._("Order saved as draft"),
|
|
"sale_order_id": sale_order.id,
|
|
}
|
|
),
|
|
[("Content-Type", "application/json")],
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
_logger.error("save_eskaera_draft: Unexpected error: %s", str(e))
|
|
_logger.error(traceback.format_exc())
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=500,
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/confirm"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
csrf=False,
|
|
)
|
|
def confirm_eskaera(self, **post):
|
|
"""Confirm order and create sale.order from cart (localStorage).
|
|
|
|
Items come from the cart stored in the frontend localStorage.
|
|
"""
|
|
import json
|
|
|
|
try:
|
|
# Initial log for debug
|
|
_logger.warning("=== CONFIRM_ESKAERA CALLED ===")
|
|
_logger.warning(
|
|
"Request data: %s",
|
|
request.httprequest.data[:200] if request.httprequest.data else "EMPTY",
|
|
)
|
|
|
|
# Decode JSON and validate using helpers
|
|
try:
|
|
data = self._decode_json_body()
|
|
except ValueError as e:
|
|
_logger.warning("confirm_eskaera: Validation error: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
try:
|
|
(
|
|
order_id,
|
|
group_order,
|
|
current_user,
|
|
items,
|
|
is_delivery,
|
|
) = self._validate_confirm_json(data)
|
|
except ValueError as e:
|
|
_logger.warning("confirm_eskaera: Validation error: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
_logger.info("Current user: %d", current_user.id)
|
|
|
|
# Process cart items using helper
|
|
try:
|
|
sale_order_lines = self._process_cart_items(
|
|
items, group_order, pricelist=self._resolve_pricelist()
|
|
)
|
|
except ValueError as e:
|
|
_logger.warning("confirm_eskaera: Cart processing error: %s", str(e))
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=400,
|
|
)
|
|
|
|
# Reuse only draft from the current group_order cycle (not stale ones)
|
|
existing_order = self._find_recent_draft_order(
|
|
current_user.partner_id.id, group_order
|
|
)
|
|
|
|
if existing_order:
|
|
_logger.info(
|
|
"Found existing draft order: %d, updating instead of creating new",
|
|
existing_order.id,
|
|
)
|
|
else:
|
|
_logger.info(
|
|
"No existing draft order found, will create new sale.order"
|
|
)
|
|
existing_order = None
|
|
|
|
effective_home_delivery, commitment_date = (
|
|
self._get_effective_delivery_context(group_order, is_delivery)
|
|
)
|
|
|
|
# Create or update sale.order using helper
|
|
sale_order = self._create_or_update_sale_order(
|
|
group_order,
|
|
current_user,
|
|
sale_order_lines,
|
|
effective_home_delivery,
|
|
commitment_date=commitment_date,
|
|
existing_order=existing_order,
|
|
)
|
|
|
|
# Build confirmation message using helper
|
|
message_data = self._build_confirmation_message(
|
|
sale_order, group_order, effective_home_delivery
|
|
)
|
|
message = message_data["message"]
|
|
pickup_day_name = message_data["pickup_day"]
|
|
pickup_date_str = message_data["pickup_date"]
|
|
pickup_day_index = message_data["pickup_day_index"]
|
|
|
|
response_data = {
|
|
"success": True,
|
|
"message": message,
|
|
"sale_order_id": sale_order.id,
|
|
"redirect_url": sale_order.get_portal_url(),
|
|
"group_order_name": group_order.name,
|
|
"pickup_day": pickup_day_name,
|
|
"pickup_date": pickup_date_str,
|
|
"pickup_day_index": pickup_day_index,
|
|
}
|
|
|
|
# Log language and final message to debug translation issues
|
|
try:
|
|
_logger.info(
|
|
'confirm_eskaera: lang=%s, message="%s"', request.env.lang, message
|
|
)
|
|
except Exception:
|
|
_logger.info("confirm_eskaera: message logging failed")
|
|
|
|
_logger.info(
|
|
"Order %d confirmed successfully, sale.order created: %d",
|
|
order_id,
|
|
sale_order.id,
|
|
)
|
|
|
|
# Confirm the sale.order (change state from draft to sale)
|
|
try:
|
|
sale_order.action_confirm()
|
|
_logger.info(
|
|
"sale.order %d confirmed (state changed to sale)", sale_order.id
|
|
)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
"Failed to confirm sale.order %d: %s", sale_order.id, str(e)
|
|
)
|
|
# Continue anyway, the order was created/updated
|
|
|
|
return request.make_response(
|
|
json.dumps(response_data), [("Content-Type", "application/json")]
|
|
)
|
|
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
_logger.error("confirm_eskaera: Unexpected error: %s", str(e))
|
|
_logger.error(traceback.format_exc())
|
|
return request.make_response(
|
|
json.dumps({"error": str(e)}),
|
|
[("Content-Type", "application/json")],
|
|
status=500,
|
|
)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:group_order_id>/load-from-history/<int:sale_order_id>"],
|
|
type="http",
|
|
auth="user",
|
|
website=True,
|
|
)
|
|
def load_order_from_history(self, group_order_id=None, sale_order_id=None, **post):
|
|
"""Load a historical order (draft/confirmed) back into the cart.
|
|
|
|
Used by portal "Load in Cart" button on My Orders page.
|
|
Extracts items from the order and redirects to the group order page,
|
|
where the JavaScript auto-load will populate the cart.
|
|
"""
|
|
try:
|
|
# Get the sale.order record
|
|
sale_order = request.env["sale.order"].sudo().browse(sale_order_id)
|
|
if not sale_order.exists():
|
|
return request.redirect("/shop")
|
|
|
|
# Verify this order belongs to current user
|
|
current_partner = request.env.user.partner_id
|
|
if sale_order.partner_id.id != current_partner.id:
|
|
_logger.warning(
|
|
"User %s attempted to load order %d belonging to partner %d",
|
|
request.env.user.login,
|
|
sale_order_id,
|
|
sale_order.partner_id.id,
|
|
)
|
|
return request.redirect("/shop")
|
|
|
|
# Verify the order belongs to the requested group_order
|
|
if sale_order.group_order_id.id != group_order_id:
|
|
return request.redirect("/eskaera/%d" % sale_order.group_order_id.id)
|
|
|
|
# Extract items from the order (skip delivery product)
|
|
# Use the delivery_product_id from the group_order
|
|
delivery_product = sale_order.group_order_id.delivery_product_id
|
|
delivery_product_id = delivery_product.id if delivery_product else None
|
|
|
|
items = []
|
|
for line in sale_order.order_line:
|
|
# Skip the delivery product
|
|
if delivery_product_id and line.product_id.id == delivery_product_id:
|
|
continue
|
|
|
|
items.append(
|
|
{
|
|
"product_id": line.product_id.id,
|
|
"product_name": line.product_id.name,
|
|
"quantity": line.product_uom_qty,
|
|
"price": line.price_unit, # Unit price
|
|
}
|
|
)
|
|
|
|
# Store items in localStorage by passing via URL parameter or session
|
|
# We'll use sessionStorage in JavaScript to avoid URL length limits
|
|
|
|
# Check if the order being loaded is from the same group order
|
|
# If not, don't restore the old pickup fields - use the current group order's fields
|
|
same_group_order = sale_order.group_order_id.id == group_order_id
|
|
|
|
# If loading from same group order, restore old pickup fields
|
|
# Otherwise, page will show current group order's pickup fields
|
|
pickup_day_to_restore = sale_order.pickup_day if same_group_order else None
|
|
pickup_date_to_restore = (
|
|
str(sale_order.pickup_date)
|
|
if (same_group_order and sale_order.pickup_date)
|
|
else None
|
|
)
|
|
home_delivery_to_restore = (
|
|
sale_order.home_delivery if same_group_order else None
|
|
)
|
|
|
|
response = request.make_response(
|
|
request.render(
|
|
"website_sale_aplicoop.eskaera_load_from_history",
|
|
{
|
|
"group_order_id": group_order_id,
|
|
"items_json": json.dumps(items), # Pass serialized JSON
|
|
"sale_order": sale_order,
|
|
"sale_order_name": sale_order.name, # Pass order reference
|
|
"pickup_day": pickup_day_to_restore, # Pass pickup day (or None if different group)
|
|
"pickup_date": pickup_date_to_restore, # Pass pickup date (or None if different group)
|
|
"home_delivery": home_delivery_to_restore, # Pass home delivery flag (or None if different group)
|
|
"same_group_order": same_group_order, # Indicate if from same group order
|
|
},
|
|
),
|
|
)
|
|
return response
|
|
|
|
except Exception as e:
|
|
_logger.error("load_order_from_history: %s", str(e))
|
|
import traceback
|
|
|
|
_logger.error(traceback.format_exc())
|
|
return request.redirect("/eskaera/%d" % group_order_id)
|
|
|
|
@http.route(
|
|
["/eskaera/<int:group_order_id>/confirm/<int:sale_order_id>"],
|
|
type="json",
|
|
auth="user",
|
|
website=True,
|
|
methods=["POST"],
|
|
)
|
|
def confirm_order_from_portal(
|
|
self, group_order_id=None, sale_order_id=None, **post
|
|
):
|
|
"""Confirm a draft order from the portal (AJAX endpoint).
|
|
|
|
Used by portal "Confirm" button on My Orders page.
|
|
Confirms the draft order and returns JSON response.
|
|
Does NOT redirect - the calling JavaScript handles the response.
|
|
"""
|
|
_logger.info(
|
|
"confirm_order_from_portal called: group_order_id=%s, sale_order_id=%s",
|
|
group_order_id,
|
|
sale_order_id,
|
|
)
|
|
|
|
try:
|
|
# Get the sale.order record
|
|
sale_order = request.env["sale.order"].sudo().browse(sale_order_id)
|
|
if not sale_order.exists():
|
|
_logger.warning(
|
|
"confirm_order_from_portal: Order %d not found", sale_order_id
|
|
)
|
|
return {"success": False, "error": "Order not found"}
|
|
|
|
# Verify this order belongs to current user
|
|
current_partner = request.env.user.partner_id
|
|
if sale_order.partner_id.id != current_partner.id:
|
|
_logger.warning(
|
|
"User %s attempted to confirm order %d belonging to partner %d",
|
|
request.env.user.login,
|
|
sale_order_id,
|
|
sale_order.partner_id.id,
|
|
)
|
|
return {"success": False, "error": "Unauthorized"}
|
|
|
|
# Verify the order belongs to the requested group_order
|
|
if sale_order.group_order_id.id != group_order_id:
|
|
_logger.warning(
|
|
"Order %d belongs to group %d, not %d",
|
|
sale_order_id,
|
|
sale_order.group_order_id.id,
|
|
group_order_id,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": f"Order belongs to different group: {sale_order.group_order_id.id}",
|
|
}
|
|
|
|
# Only allow confirming draft orders
|
|
if sale_order.state != "draft":
|
|
_logger.warning(
|
|
"Order %d is in state %s, not draft",
|
|
sale_order_id,
|
|
sale_order.state,
|
|
)
|
|
return {
|
|
"success": False,
|
|
"error": f"Order is already {sale_order.state}, cannot confirm again",
|
|
}
|
|
|
|
# Confirm the order (change state to 'sale')
|
|
sale_order.action_confirm()
|
|
_logger.info(
|
|
"Order %d confirmed from portal by user %s",
|
|
sale_order_id,
|
|
request.env.user.login,
|
|
)
|
|
|
|
# Return success response with updated order state
|
|
return {
|
|
"success": True,
|
|
"message": request.env._("Order confirmed successfully"),
|
|
"order_id": sale_order_id,
|
|
"order_state": sale_order.state,
|
|
"group_order_id": group_order_id,
|
|
}
|
|
|
|
except Exception as e:
|
|
_logger.error("confirm_order_from_portal: %s", str(e))
|
|
import traceback
|
|
|
|
_logger.error(traceback.format_exc())
|
|
return {"success": False, "error": f"Error confirming order: {str(e)}"}
|
|
|
|
def _translate_labels(self, labels_dict, lang):
|
|
"""Manually translate labels based on user language.
|
|
|
|
This is a fallback translation method for when Odoo's translation system
|
|
hasn't loaded translations from .po files properly.
|
|
"""
|
|
translations = {
|
|
"es_ES": {
|
|
"Draft Already Exists": "El Borrador Ya Existe",
|
|
"A saved draft already exists for the current order period.": "Un borrador guardado ya existe para el período actual del pedido.",
|
|
"You have two options:": "Tienes dos opciones:",
|
|
"Option 1: Merge with Existing Draft": "Opción 1: Fusionar con Borrador Existente",
|
|
"Combine your current cart with the existing draft.": "Combina tu carrito actual con el borrador existente.",
|
|
"Existing draft has": "El borrador existente tiene",
|
|
"Current cart has": "Tu carrito actual tiene",
|
|
"item(s)": "artículo(s)",
|
|
"Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Los productos se fusionarán sumando cantidades. Si un producto existe en ambos, las cantidades se combinarán.",
|
|
"Option 2: Replace with Current Cart": "Opción 2: Reemplazar con Carrito Actual",
|
|
"Delete the old draft and save only the current cart items.": "Elimina el borrador anterior y guarda solo los artículos del carrito actual.",
|
|
"The existing draft will be permanently deleted.": "El borrador existente se eliminará permanentemente.",
|
|
"Merge": "Fusionar",
|
|
"Replace": "Reemplazar",
|
|
"Cancel": "Cancelar",
|
|
# Checkout page labels
|
|
"Home Delivery": "Entrega a Domicilio",
|
|
"Delivery Information": "Información de Entrega",
|
|
"Your order will be delivered the day after pickup between 11:00 - 14:00": "Tu pedido será entregado el día después de la recogida entre las 11:00 - 14:00",
|
|
"Important": "Importante",
|
|
"Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Una vez confirmes este pedido, no podrás modificarlo. Por favor, revisa cuidadosamente antes de confirmar.",
|
|
},
|
|
"eu_ES": {
|
|
"Draft Already Exists": "Zirriborro Dagoeneko Badago",
|
|
"A saved draft already exists for the current order period.": "Gordetako zirriborro bat dagoeneko badago uneko eskaera-aldirako.",
|
|
"You have two options:": "Bi aukera dituzu:",
|
|
"Option 1: Merge with Existing Draft": "1. Aukera: Existentea Duen Zirriborroarekin Batu",
|
|
"Combine your current cart with the existing draft.": "Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.",
|
|
"Existing draft has": "Existentea duen zirriborroak du",
|
|
"Current cart has": "Zure gaur-oraingo saskiak du",
|
|
"item(s)": "artikulu(a)",
|
|
"Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.",
|
|
"Option 2: Replace with Current Cart": "2. Aukera: Gaur-oraingo Askiarekin Ordeztu",
|
|
"Delete the old draft and save only the current cart items.": "Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.",
|
|
"The existing draft will be permanently deleted.": "Existentea duen zirriborroa behin betiko ezabatuko da.",
|
|
"Merge": "Batu",
|
|
"Replace": "Ordeztu",
|
|
"Cancel": "Ezeztatu",
|
|
# Checkout page labels
|
|
"Home Delivery": "Etxera Bidalketa",
|
|
"Delivery Information": "Bidalketaren Informazioa",
|
|
"Your order will be delivered the day after pickup between 11:00 - 14:00": "Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean",
|
|
"Important": "Garrantzitsua",
|
|
"Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.",
|
|
},
|
|
# Also support 'eu' as a variant
|
|
"eu": {
|
|
"Draft Already Exists": "Zirriborro Dagoeneko Badago",
|
|
"A saved draft already exists for the current order period.": "Gordetako zirriborro bat dagoeneko badago uneko eskaera-aldirako.",
|
|
"You have two options:": "Bi aukera dituzu:",
|
|
"Option 1: Merge with Existing Draft": "1. Aukera: Existentea Duen Zirriborroarekin Batu",
|
|
"Combine your current cart with the existing draft.": "Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.",
|
|
"Existing draft has": "Existentea duen zirriborroak du",
|
|
"Current cart has": "Zure gaur-oraingo saskiak du",
|
|
"item(s)": "artikulu(a)",
|
|
"Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.",
|
|
"Option 2: Replace with Current Cart": "2. Aukera: Gaur-oraingo Askiarekin Ordeztu",
|
|
"Delete the old draft and save only the current cart items.": "Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.",
|
|
"The existing draft will be permanently deleted.": "Existentea duen zirriborroa behin betiko ezabatuko da.",
|
|
"Merge": "Batu",
|
|
"Replace": "Ordeztu",
|
|
"Cancel": "Ezeztatu",
|
|
# Checkout page labels
|
|
"Home Delivery": "Etxera Bidalketa",
|
|
"Delivery Information": "Bidalketaren Informazioa",
|
|
"Your order will be delivered the day after pickup between 11:00 - 14:00": "Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean",
|
|
"Important": "Garrantzitsua",
|
|
"Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.",
|
|
},
|
|
}
|
|
|
|
# Get the translation dictionary for the user's language
|
|
# Try exact match first, then try without the region code (e.g., 'eu' from 'eu_ES')
|
|
lang_translations = translations.get(lang)
|
|
if not lang_translations and "_" in lang:
|
|
lang_code = lang.split("_")[0] # Get 'eu' from 'eu_ES'
|
|
lang_translations = translations.get(lang_code, {})
|
|
if not lang_translations:
|
|
lang_translations = {}
|
|
|
|
# Translate all English labels to the target language
|
|
translated = {}
|
|
for key, english_label in labels_dict.items():
|
|
translated[key] = lang_translations.get(english_label, english_label)
|
|
|
|
_logger.info(
|
|
"[_translate_labels] Language: %s, Translated %d labels",
|
|
lang,
|
|
len(translated),
|
|
)
|
|
|
|
return translated
|
|
|
|
@http.route(
|
|
["/eskaera/labels", "/eskaera/i18n"],
|
|
type="json",
|
|
auth="public",
|
|
website=True,
|
|
csrf=False,
|
|
)
|
|
def get_checkout_labels(self, **post):
|
|
"""Return ALL translated UI labels and messages unified.
|
|
|
|
This is the SINGLE API ENDPOINT for fetching all user-facing translations.
|
|
Use this from JavaScript instead of maintaining local translation files.
|
|
|
|
The endpoint automatically detects the user's language and returns all
|
|
UI labels/messages in that language, ready to be used directly.
|
|
|
|
Returns:
|
|
dict: Complete set of translated labels and messages
|
|
"""
|
|
try:
|
|
lang = self._get_detected_language(**post)
|
|
labels = self._get_translated_labels(lang)
|
|
|
|
_logger.info(
|
|
"[get_checkout_labels] ✅ SUCCESS - Language: %s, Label count: %d",
|
|
lang,
|
|
len(labels),
|
|
)
|
|
|
|
return labels
|
|
except Exception as e:
|
|
_logger.error("[get_checkout_labels] ❌ ERROR: %s", str(e), exc_info=True)
|
|
# Return default English labels as fallback
|
|
return {
|
|
"save_cart": "Save Cart",
|
|
"reload_cart": "Reload Cart",
|
|
"empty_cart": "Your cart is empty",
|
|
"added_to_cart": "added to cart",
|
|
}
|