diff --git a/.pylintrc b/.pylintrc index 6dfee34..20cb606 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,8 +1,6 @@ [MASTER] load-plugins=pylint_odoo score=n -# Exclude virtual environment directory from pylint analysis -ignore=.venv [MESSAGES CONTROL] disable=all diff --git a/setup.cfg b/setup.cfg index 2e65587..070a0a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ max-line-length = 88 max-complexity = 16 select = C,E,F,W,B,B9 ignore = E203,E501,W503,B950 -exclude = scripts/, .venv/ +exclude = scripts/ [isort] profile = black diff --git a/stock_picking_batch_custom/__manifest__.py b/stock_picking_batch_custom/__manifest__.py index 23d05c5..1273545 100644 --- a/stock_picking_batch_custom/__manifest__.py +++ b/stock_picking_batch_custom/__manifest__.py @@ -11,9 +11,6 @@ "license": "AGPL-3", "depends": [ "stock_picking_batch", - # Ensure our related fields to sale/picking (home_delivery, pickup_slot_label) - # are available by depending on the Aplicoop website_sale extension. - "website_sale_aplicoop", ], "data": [ "security/ir.model.access.csv", @@ -21,9 +18,4 @@ "views/stock_move_line_views.xml", "views/stock_picking_batch_views.xml", ], - "assets": { - "web.assets_backend": [ - "stock_picking_batch_custom/static/src/css/stock_picking_batch.css", - ], - }, } diff --git a/stock_picking_batch_custom/models/stock_move_line.py b/stock_picking_batch_custom/models/stock_move_line.py index 1336eaa..a388884 100644 --- a/stock_picking_batch_custom/models/stock_move_line.py +++ b/stock_picking_batch_custom/models/stock_move_line.py @@ -23,11 +23,6 @@ class StockMoveLine(models.Model): copy=False, ) - home_delivery = fields.Boolean( - related="picking_id.home_delivery", - readonly=True, - ) - consumer_group_id = fields.Many2one( comodel_name="res.partner", compute="_compute_consumer_group_id", diff --git a/stock_picking_batch_custom/static/src/css/stock_picking_batch.css b/stock_picking_batch_custom/static/src/css/stock_picking_batch.css deleted file mode 100644 index 52f70bb..0000000 --- a/stock_picking_batch_custom/static/src/css/stock_picking_batch.css +++ /dev/null @@ -1,18 +0,0 @@ -/* zebra striping for list views in this module */ - -/* Target Odoo list/tree view tables. Use a specific, but broad selector to - avoid interfering globally with other modules. */ -.o_list_view .o_list_view_table tbody tr:nth-child(even) td { - background-color: rgba(0, 0, 0, 0.03); -} - -/* Slight hover contrast to improve row focus */ -.o_list_view .o_list_view_table tbody tr:hover td { - background-color: rgba(0, 0, 0, 0.045); -} - -/* Ensure checkboxes / toggle columns maintain contrast */ -.o_list_view .o_list_view_table tbody tr:nth-child(even) td .o_field_widget, -.o_list_view .o_list_view_table tbody tr:nth-child(even) td .o_field_widget * { - background: transparent; -} diff --git a/stock_picking_batch_custom/views/stock_move_line_views.xml b/stock_picking_batch_custom/views/stock_move_line_views.xml index 7a294d3..7fbc218 100644 --- a/stock_picking_batch_custom/views/stock_move_line_views.xml +++ b/stock_picking_batch_custom/views/stock_move_line_views.xml @@ -26,7 +26,6 @@ - diff --git a/stock_picking_batch_custom/views/stock_picking_batch_views.xml b/stock_picking_batch_custom/views/stock_picking_batch_views.xml index 35fe168..0c6b75e 100644 --- a/stock_picking_batch_custom/views/stock_picking_batch_views.xml +++ b/stock_picking_batch_custom/views/stock_picking_batch_views.xml @@ -5,10 +5,8 @@ stock.picking.batch.summary.line - - - + @@ -41,6 +39,4 @@ - - diff --git a/website_sale_aplicoop/README.rst b/website_sale_aplicoop/README.rst index bf621c1..40da165 100644 --- a/website_sale_aplicoop/README.rst +++ b/website_sale_aplicoop/README.rst @@ -141,49 +141,3 @@ This module was inspired by the original **Aplicoop** project: * Original creators: Ekaitz Mendiluze, Joseba Legarreta, and other contributors The original Aplicoop project served as a pioneering solution for collaborative consumption group orders, and this module brings its functionality to the modern Odoo platform. - -Notes - Shop behaves as a simple catalog -========================================= - -Starting with the recent update, this module converts the default Odoo -``/shop`` storefront into a simple product catalog (no standard ``website_sale`` -shopping cart). The change is intentional for sites that use the Aplicoop -"eskaera" flow as the single shopping experience. - -What the module does ---------------------- - -- Hides the standard header cart link and badge. -- Removes the "Add to cart" quick-add area from product listings. -- Redirects standard cart endpoints to the group-order flow (``/eskaera``): - ``/shop/cart``, ``/shop/cart/update``, ``/shop/cart/update_json``, ``/shop/cart_quantity``. - -Files involved --------------- - -- ``views/website_sale_disable_cart.xml`` — templates that hide/remove cart UI -- ``controllers/website_sale.py`` — routes that redirect cart endpoints to ``/eskaera`` -- ``__manifest__.py`` — includes the new view file - -How to apply or revert ------------------------ - -To apply the change (already applied when the module is installed/updated): - -:: - - docker-compose run --rm odoo odoo -d odoo -u website_sale_aplicoop --stop-after-init - docker-compose up -d - -To revert back to the standard ``website_sale`` behaviour: - -1. Remove ``views/website_sale_disable_cart.xml`` from the ``data`` section in - ``__manifest__.py``. -2. Update the module: - -:: - - docker-compose run --rm odoo odoo -d odoo -u website_sale_aplicoop --stop-after-init - -Note: Reverting may expose standard cart UI and routes; ensure your site -content and workflows are adapted accordingly. diff --git a/website_sale_aplicoop/__manifest__.py b/website_sale_aplicoop/__manifest__.py index e820bd8..3de62ce 100644 --- a/website_sale_aplicoop/__manifest__.py +++ b/website_sale_aplicoop/__manifest__.py @@ -3,7 +3,7 @@ { # noqa: B018 "name": "Website Sale - Aplicoop", - "version": "18.0.1.9.0", + "version": "18.0.1.8.0", "category": "Website/Sale", "summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders", "author": "Odoo Community Association (OCA), Criptomart", @@ -34,11 +34,9 @@ "security/record_rules.xml", # Vistas "views/group_order_views.xml", - "views/product_category_views.xml", "views/res_partner_views.xml", "views/res_config_settings_views.xml", "views/website_templates.xml", - "views/website_sale_disable_cart.xml", "views/product_template_views.xml", "views/sale_order_views.xml", "views/stock_picking_views.xml", diff --git a/website_sale_aplicoop/controllers/exceptions.py b/website_sale_aplicoop/controllers/exceptions.py deleted file mode 100644 index 81ca4f3..0000000 --- a/website_sale_aplicoop/controllers/exceptions.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Shared exceptions for website_sale_aplicoop controllers helpers. - -These are imported by helper modules to avoid circular imports with -`website_sale.py` when splitting helpers into separate files. -""" - - -class BadRequestError(Exception): - pass - - -class ForbiddenError(Exception): - pass - - -class GroupOrderUnavailable(Exception): - pass diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index aab682f..ebdf223 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -3,6 +3,8 @@ import json import logging +from datetime import datetime +from datetime import timedelta from odoo import fields from odoo import http @@ -10,16 +12,6 @@ from odoo.http import request from odoo.addons.website_sale.controllers.main import WebsiteSale -from . import website_sale_i18n as _i18n -from . import website_sale_pickup as _pickup -from . import website_sale_pricing as _pricing -from . import website_sale_products as _products -from . import website_sale_utils as _utils -from . import website_sale_validators as _validators -from .exceptions import BadRequestError -from .exceptions import ForbiddenError -from .exceptions import GroupOrderUnavailable - _logger = logging.getLogger(__name__) @@ -31,66 +23,765 @@ class AplicoopWebsiteSale(WebsiteSale): """ def _get_day_names(self, env=None): - """Delegate day names lookup to pickup helper module.""" - return _pickup._get_day_names(self, env=env, request_obj=request) + """Get translated day names list (0=Monday to 6=Sunday). - def _get_next_date_for_weekday(self, weekday_num, start_date=None): - """Delegate next-date computation to pickup helper.""" - return _pickup._get_next_date_for_weekday( - self, weekday_num, start_date=start_date + 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): - return _i18n._get_detected_language(self, request, **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): - return _i18n._get_translated_labels(self, lang, request) + """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): - return _products._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): - return _pricing._resolve_pricelist(self, request) + """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): - return _pricing._prepare_product_display_info( - self, - product, - product_price_info, - request, + """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): - return _pricing._get_pricing_info( - self, - product, - pricelist, + """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, - partner=partner, - request_obj=request, + 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): - return _pricing._compute_price_info(self, products, pricelist, request) + """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 _pricing._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 _pricing._get_delivery_product_display_price( - self, - delivery_product, - pricelist=pricelist, - request_obj=request, - ) + """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): - return _products._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): - return _validators._validate_confirm_request(self, data, request) + """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. @@ -111,7 +802,52 @@ class AplicoopWebsiteSale(WebsiteSale): ValueError: if any validation fails """ - return _validators._validate_draft_request(self, data, request) + # 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. @@ -131,10 +867,65 @@ class AplicoopWebsiteSale(WebsiteSale): Raises: ValueError: if any validation fails """ - return _validators._validate_confirm_json(self, data, request) + # 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): - return _validators._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. @@ -224,13 +1015,57 @@ class AplicoopWebsiteSale(WebsiteSale): return sale_order_lines def _get_salesperson_for_order(self, partner): - return _validators._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 _validators._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): - return _validators._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, @@ -438,86 +1273,49 @@ class AplicoopWebsiteSale(WebsiteSale): } def _format_pickup_info(self, group_order, is_delivery): - return _pickup._format_pickup_info( - self, - group_order, - is_delivery, - request_obj=request, - ) + """Return (pickup_day_name, pickup_date_str, pickup_day_index) localized. - def _slot_time_label(self, slot): - return _pickup._slot_time_label(self, slot) - - def _format_datetime_to_str(self, dt_val): - return _pickup._format_datetime_to_str(self, dt_val) - - def _format_slot_pickup_info(self, group_order, slot): - return _pickup._format_slot_pickup_info( - self, - group_order, - slot, - request_obj=request, - ) - - def _format_legacy_pickup_info(self, group_order, is_delivery): - return _pickup._format_legacy_pickup_info( - self, - group_order, - is_delivery, - request_obj=request, - ) - - def _parse_save_cart_request(self): - """Decode and validate the incoming save-cart request. - - Returns a tuple: (data, order_id, group_order, current_user, items, pickup_date, is_delivery) - Raises: - BadRequestError, ForbiddenError, GroupOrderUnavailable + Encapsulates day name detection and date formatting to reduce method complexity. """ - data = self._decode_json_body() - - order_id = data.get("order_id") - if not order_id: - raise BadRequestError("order_id is required") + # Get pickup day index try: - order_id = int(order_id) - except (TypeError, ValueError) as e: - # Preserve the original exception context - raise BadRequestError(f"Invalid order_id format: {order_id}") from e + pickup_day_index = int(group_order.pickup_day) + except Exception: + pickup_day_index = None - group_order = request.env["group.order"].sudo().browse(order_id) - if not group_order.exists(): - raise BadRequestError(f"Order {order_id} not found") + pickup_day_name = "" + pickup_date_str = "" - if group_order.state != "open": - # Upstream handler expects a special response for unavailable orders - raise GroupOrderUnavailable() + # 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 = "" - current_user = request.env.user - if not current_user.partner_id: - raise BadRequestError("User has no associated partner") + # 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") - try: - self._validate_user_group_access(group_order, current_user) - except ValueError as e: - # Preserve exception chaining for better debugging - raise ForbiddenError(str(e)) from e - - items = data.get("items", []) - pickup_date = data.get("pickup_date") - is_delivery = self._to_bool(data.get("is_delivery", False)) - if not items: - raise BadRequestError("No items in cart") - - return ( - data, - order_id, - group_order, - current_user, - items, - pickup_date, - is_delivery, - ) + return pickup_day_name, pickup_date_str, pickup_day_index @http.route(["/eskaera"], type="http", auth="user", website=True) def eskaera_list(self, **post): @@ -561,16 +1359,63 @@ class AplicoopWebsiteSale(WebsiteSale): ) def _filter_published_tags(self, tags): - """Delegate tag filtering to products helper.""" - return _products._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): - """Delegate product/category collection to products helper.""" - return _products._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): - """Delegate preparation of product maps to products helper.""" - return _products._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, @@ -639,22 +1484,31 @@ class AplicoopWebsiteSale(WebsiteSale): return sale_order def _decode_json_body(self): - return _utils._decode_json_body(self, request) + """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): - """Delegate building of unavailable response to utils helper.""" - self._request = request - return _utils._build_group_order_unavailable_response( - self, group_order, status=status - ) - - def _validate_items_for_group_order(self, items, group_order): - """Delegate availability validation to validators helper.""" - return _validators._validate_items_for_group_order( - self, - items, - group_order, - request, + """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): @@ -667,11 +1521,30 @@ class AplicoopWebsiteSale(WebsiteSale): Returns the recordset (limit=1) or empty recordset. """ - return _validators._find_recent_draft_order( - self, - partner_id, - group_order, - request, + 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/"], type="http", auth="user", website=True) @@ -1345,31 +2218,12 @@ class AplicoopWebsiteSale(WebsiteSale): status=404, ) - # Determine if cutoff date for the current cycle has already passed - try: - today = fields.Date.today() - cutoff_passed = False - cutoff_date_str = None - if group_order.cutoff_date: - cutoff_passed = group_order.cutoff_date < today - # Convert to ISO-like string for frontend (YYYY-MM-DD) - cutoff_date_str = str(group_order.cutoff_date) - except Exception: - cutoff_passed = False - cutoff_date_str = None - 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" or cutoff_passed) - else "none" - ), - "cutoff_passed": cutoff_passed, - "cutoff_date": cutoff_date_str, + "action": "clear_cart" if group_order.state != "open" else "none", } return request.make_response( json.dumps(response_data), @@ -1385,53 +2239,76 @@ class AplicoopWebsiteSale(WebsiteSale): csrf=False, ) def save_cart_draft(self, **post): - """Save cart items as a draft sale.order with pickup date. - - This controller delegates validation and heavy lifting to helpers - so the top-level flow remains easy to follow and McCabe-friendly. - """ + """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, - order_id, - group_order, - current_user, - items, - pickup_date, - is_delivery, - ) = self._parse_save_cart_request() - except BadRequestError as e: + data = self._decode_json_body() + except ValueError as e: return request.make_response( json.dumps({"error": str(e)}), [("Content-Type", "application/json")], status=400, ) - except ForbiddenError as e: + + _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, ) - except GroupOrderUnavailable: - order_id = None - try: - payload = self._decode_json_body() - order_id = ( - int(payload.get("order_id")) - if payload.get("order_id") - else None - ) - except Exception: - order_id = None - group_order = ( - request.env["group.order"].sudo().browse(order_id) - if order_id - else False + + 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, ) - return self._build_group_order_unavailable_response(group_order) # Build sale.order lines and create draft using helpers try: @@ -1461,29 +2338,12 @@ class AplicoopWebsiteSale(WebsiteSale): current_user.partner_id.id, ) - # Compute a readable pickup slot label for the response. Prefer the - # order's stored computed label, otherwise derive from the group - # order using the same helper the confirmation flow uses. - pickup_slot_label = ( - sale_order.pickup_slot_label - if getattr(sale_order, "pickup_slot_label", False) - else None - ) - if not pickup_slot_label: - try: - pickup_slot_label = self._format_pickup_info( - group_order, is_delivery - )[0] - except Exception: - pickup_slot_label = None - return request.make_response( json.dumps( { "success": True, "message": request.env._("Cart saved as draft"), "sale_order_id": sale_order.id, - "pickup_slot_label": pickup_slot_label, } ), [("Content-Type", "application/json")], @@ -1627,14 +2487,6 @@ class AplicoopWebsiteSale(WebsiteSale): if draft_order.pickup_date else None ), - # Provide a human readable pickup slot label so the frontend - # doesn't need to compute/lookup slots. This may be empty - # but it's the preferred source for displaying pickup info. - "pickup_slot_label": ( - draft_order.pickup_slot_label - if getattr(draft_order, "pickup_slot_label", False) - else None - ), "home_delivery": draft_order.home_delivery, } ), @@ -1660,7 +2512,7 @@ class AplicoopWebsiteSale(WebsiteSale): methods=["POST"], csrf=False, ) - def eskaera_clear_cart(self, **post): + def clear_cart(self, **post): """Clear the user's cart and cancel any existing draft sale.order. Receives: JSON body with 'order_id' @@ -1764,52 +2616,111 @@ class AplicoopWebsiteSale(WebsiteSale): 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, delegate merge/replace to helper. + 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 ===") - try: - ( - data, - order_id, - group_order, - current_user, - items, - pickup_date, - is_delivery, - ) = self._parse_save_cart_request() - except BadRequestError as e: + if not request.httprequest.data: + _logger.warning("save_eskaera_draft: No request data provided") return request.make_response( - json.dumps({"error": str(e)}), + json.dumps({"error": "No data provided"}), [("Content-Type", "application/json")], status=400, ) - except ForbiddenError as e: + + # 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, ) - except GroupOrderUnavailable: - order_id = None - try: - payload = self._decode_json_body() - order_id = ( - int(payload.get("order_id")) - if payload.get("order_id") - else None - ) - except Exception: - order_id = None - group_order = ( - request.env["group.order"].sudo().browse(order_id) - if order_id - else False - ) - return self._build_group_order_unavailable_response(group_order) + # 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 ) @@ -1820,6 +2731,7 @@ class AplicoopWebsiteSale(WebsiteSale): 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() @@ -1831,6 +2743,7 @@ class AplicoopWebsiteSale(WebsiteSale): status=400, ) + # Delegate merge/replace/create logic to helper sale_order = self._merge_or_replace_draft( group_order, current_user, @@ -1841,31 +2754,21 @@ class AplicoopWebsiteSale(WebsiteSale): ) _logger.info( - "Draft sale.order created/updated: %d for partner %d", + "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, ) - pickup_slot_label = ( - sale_order.pickup_slot_label - if getattr(sale_order, "pickup_slot_label", False) - else None - ) - if not pickup_slot_label: - try: - pickup_slot_label = self._format_pickup_info( - group_order, is_delivery - )[0] - except Exception: - pickup_slot_label = None - return request.make_response( json.dumps( { "success": True, "message": request.env._("Order saved as draft"), "sale_order_id": sale_order.id, - "pickup_slot_label": pickup_slot_label, } ), [("Content-Type", "application/json")], @@ -1997,14 +2900,6 @@ class AplicoopWebsiteSale(WebsiteSale): "pickup_day_index": pickup_day_index, } - # Also include the human-readable pickup slot label to simplify - # client-side rendering (may be None). - response_data["pickup_slot_label"] = ( - sale_order.pickup_slot_label - if getattr(sale_order, "pickup_slot_label", False) - else pickup_day_name - ) - # Log language and final message to debug translation issues try: _logger.info( @@ -2080,14 +2975,9 @@ class AplicoopWebsiteSale(WebsiteSale): if sale_order.group_order_id.id != group_order_id: return request.redirect("/eskaera/%d" % sale_order.group_order_id.id) - # Get the current group_order (the one being viewed, not necessarily the one from the history) - group_order = request.env["group.order"].sudo().browse(group_order_id) - if not group_order.exists(): - return request.redirect("/shop") - # Extract items from the order (skip delivery product) # Use the delivery_product_id from the group_order - delivery_product = group_order.delivery_product_id + delivery_product = sale_order.group_order_id.delivery_product_id delivery_product_id = delivery_product.id if delivery_product else None items = [] @@ -2105,21 +2995,6 @@ class AplicoopWebsiteSale(WebsiteSale): } ) - # Validate items against current group order availability - validation_result = self._validate_items_for_group_order(items, group_order) - available_items = validation_result["available_items"] - unavailable_items = validation_result["unavailable_items"] - warning_message = validation_result["warning_message"] - - _logger.info( - "load_order_from_history: Loaded %d items, %d available, %d unavailable from sale_order %d into group_order %d", - len(items), - len(available_items), - len(unavailable_items), - sale_order_id, - group_order_id, - ) - # Store items in localStorage by passing via URL parameter or session # We'll use sessionStorage in JavaScript to avoid URL length limits @@ -2138,35 +3013,19 @@ class AplicoopWebsiteSale(WebsiteSale): home_delivery_to_restore = ( sale_order.home_delivery if same_group_order else None ) - # Only restore a human-readable label for the pickup slot. Do NOT - # restore or expose internal slot IDs to the frontend. - pickup_slot_label_to_restore = ( - sale_order.pickup_slot_label - if same_group_order and sale_order.pickup_slot_label - 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( - available_items - ), # Pass ONLY available items + "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) - # Do NOT pass slot IDs to the client. Only the readable - # label is useful for the UI and is safe to expose. - "pickup_slot_label": pickup_slot_label_to_restore, "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 - "unavailable_items": unavailable_items, # List of unavailable items - "warning_message": warning_message, # Warning about unavailable products - "has_unavailable_items": len(unavailable_items) - > 0, # Boolean flag for template }, ), ) @@ -2410,50 +3269,3 @@ class AplicoopWebsiteSale(WebsiteSale): "empty_cart": "Your cart is empty", "added_to_cart": "added to cart", } - - # ================================================================ - # CART REDIRECT METHODS - Redirect /shop/cart routes to /eskaera - # ================================================================ - - @http.route(["/shop/cart"], type="http", auth="public", website=True) - def cart_redirect(self, access_token=None, revive="", **post): - """Redirect /shop/cart to /eskaera (no standard cart).""" - _logger.info("🛒 Redirecting /shop/cart → /eskaera") - return http.redirect_with_hash("/eskaera") - - @http.route( - ["/shop/cart/update"], - type="http", - auth="public", - website=True, - methods=["POST"], - ) - def cart_update_redirect(self, **post): - """Redirect /shop/cart/update to /eskaera (no standard cart).""" - _logger.info("🛒 Redirecting /shop/cart/update → /eskaera") - return http.redirect_with_hash("/eskaera") - - @http.route( - ["/shop/cart/update_json"], - type="http", - auth="public", - website=True, - methods=["POST"], - csrf=False, - ) - def cart_update_json_redirect(self, **post): - """Redirect /shop/cart/update_json to /eskaera (no standard cart).""" - _logger.info("🛒 Redirecting /shop/cart/update_json → /eskaera") - return http.redirect_with_hash("/eskaera") - - @http.route( - ["/shop/cart_quantity"], - type="http", - auth="public", - website=True, - methods=["GET"], - ) - def cart_quantity_redirect(self): - """Redirect /shop/cart_quantity to /eskaera (no standard cart).""" - _logger.info("🛒 Redirecting /shop/cart_quantity → /eskaera") - return http.redirect_with_hash("/eskaera") diff --git a/website_sale_aplicoop/controllers/website_sale_i18n.py b/website_sale_aplicoop/controllers/website_sale_i18n.py deleted file mode 100644 index f5e911b..0000000 --- a/website_sale_aplicoop/controllers/website_sale_i18n.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging - -from odoo.http import request - -_logger = logging.getLogger(__name__) - - -def _get_detected_language(self, request_obj=None, **post): - req = request_obj or request - url_lang = req.params.get("lang") - post_lang = post.get("lang") - cookie_lang = req.httprequest.cookies.get("lang") - context_lang = req.env.context.get("lang") - user_lang = req.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, request_obj=None): - req = request_obj or request - if lang is None: - lang = _get_detected_language(self, request_obj=req) - - env_lang = req.env(context=dict(req.env.context, lang=lang)) - - 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."), - # ... keep minimal set here; website_sale.py will fall back if needed - } - - return labels - - -def _translate_labels(self, labels_dict, lang): - # Minimal fallback translator kept here; prefers env translations - translations = { - "es_ES": {}, - } - lang_translations = translations.get(lang, {}) - 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 diff --git a/website_sale_aplicoop/controllers/website_sale_pickup.py b/website_sale_aplicoop/controllers/website_sale_pickup.py deleted file mode 100644 index 58f2a70..0000000 --- a/website_sale_aplicoop/controllers/website_sale_pickup.py +++ /dev/null @@ -1,157 +0,0 @@ -import logging -from datetime import datetime -from datetime import timedelta - -from odoo import fields -from odoo.http import request - -_logger = logging.getLogger(__name__) - - -def _get_day_names(self, env=None, request_obj=None): - """Get translated day names list (0=Monday to 6=Sunday).""" - req = request_obj or request - if env is None: - env = req.env - - 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"] - fields_def = group_order_model.fields_get(["pickup_day"]) - selection_options = fields_def.get("pickup_day", {}).get("selection", []) - 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): - if start_date is None: - start_date = datetime.now().date() - target_weekday = int(weekday_num) - current_weekday = start_date.weekday() - days_ahead = target_weekday - current_weekday - if days_ahead <= 0: - days_ahead += 7 - return start_date + timedelta(days=days_ahead) - - -def _slot_time_label(self, slot): - try: - if slot.label: - return slot.label - sh = float(slot.start_hour or 0.0) - eh = float(slot.end_hour or 0.0) - sh_h = int(sh) - sh_m = int(round((sh - sh_h) * 60)) - eh_h = int(eh) - eh_m = int(round((eh - eh_h) * 60)) - return f"{sh_h:02d}:{sh_m:02d}-{eh_h:02d}:{eh_m:02d}" - except Exception as exc: - _logger.debug( - "_slot_time_label: failed for slot %s: %s", getattr(slot, "id", None), exc - ) - return "" - - -def _format_datetime_to_str(self, dt_val): - if not dt_val: - return "" - try: - if isinstance(dt_val, str): - dt = fields.Datetime.to_datetime(dt_val) - return dt.strftime("%d/%m/%Y") - if hasattr(dt_val, "strftime"): - return dt_val.strftime("%d/%m/%Y") - return str(dt_val) - except Exception: - return str(dt_val) - - -def _format_slot_pickup_info(self, group_order, slot, request_obj=None): - pickup_day_name = "" - pickup_date_str = "" - pickup_day_index = None - try: - pickup_day_index = int(slot.weekday) - except Exception: - pickup_day_index = None - - if pickup_day_index is not None: - try: - req = request_obj or request - day_names = _get_day_names(self, env=req.env, request_obj=req) - pickup_day_name = day_names[pickup_day_index % len(day_names)] - except Exception: - pickup_day_name = "" - - time_label = _slot_time_label(self, slot) - - dt_val = getattr(group_order, "next_pickup_datetime", None) or getattr( - group_order, "pickup_date", None - ) - pickup_date_str = _format_datetime_to_str(self, dt_val) - - if time_label: - pickup_day_name = ( - f"{pickup_day_name} {time_label}" if pickup_day_name else time_label - ) - - return pickup_day_name, pickup_date_str, pickup_day_index - - -def _format_legacy_pickup_info(self, group_order, is_delivery, request_obj=None): - pickup_day_name = "" - pickup_date_str = "" - pickup_day_index = None - try: - pickup_day_index = int(group_order.pickup_day) - except Exception: - pickup_day_index = None - - if pickup_day_index is not None: - try: - req = request_obj or request - day_names = _get_day_names(self, env=req.env, request_obj=req) - pickup_day_name = day_names[pickup_day_index % len(day_names)] - except Exception: - pickup_day_name = "" - - if group_order.pickup_date: - if is_delivery: - if group_order.delivery_date: - pickup_date_str = group_order.delivery_date.strftime("%d/%m/%Y") - if pickup_day_index is not None: - try: - req = request_obj or request - day_names = _get_day_names(self, env=req.env, request_obj=req) - 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: - pickup_date_str = group_order.pickup_date.strftime("%d/%m/%Y") - - return pickup_day_name, pickup_date_str, pickup_day_index - - -def _format_pickup_info(self, group_order, is_delivery, request_obj=None): - slot = getattr(group_order, "next_pickup_slot_id", False) or False - if slot: - return _format_slot_pickup_info( - self, - group_order, - slot, - request_obj=request_obj, - ) - return _format_legacy_pickup_info( - self, - group_order, - is_delivery, - request_obj=request_obj, - ) diff --git a/website_sale_aplicoop/controllers/website_sale_pricing.py b/website_sale_aplicoop/controllers/website_sale_pricing.py deleted file mode 100644 index a4d7f7a..0000000 --- a/website_sale_aplicoop/controllers/website_sale_pricing.py +++ /dev/null @@ -1,307 +0,0 @@ -import logging - -from odoo import fields -from odoo.http import request - -_logger = logging.getLogger(__name__) - - -def _resolve_pricelist(self, request_obj=None): - try: - req = request_obj or request - env = req.env - website = req.website - except RuntimeError: - env = getattr(self, "env", None) or self.env - website = env["website"].get_current_website() - pricelist = None - 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) - - if not pricelist: - try: - pricelist = website._get_current_pricelist() - except Exception as e: - _logger.warning( - "_resolve_pricelist: fallback to website pricelist failed: %s", e - ) - - if not pricelist: - pricelist = env["product.pricelist"].sudo().search([], limit=1) - - return pricelist - - -def _prepare_product_display_info(self, product, product_price_info, request_obj=None): - 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: - try: - req = request_obj or request - ir_model_data = req.env["ir.model.data"].sudo() - except RuntimeError: - ir_model_data = product.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), - ) - - try: - req = request_obj or request - tr_env = req.env - except RuntimeError: - tr_env = product.env - out_of_stock_label = tr_env._("Out of stock") - add_to_cart_label = tr_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, - request_obj=None, -): - req = request_obj or request - try: - env = req.env - website = req.website - except RuntimeError: - env = product.env - website = env["website"].get_current_website() - - partner = partner or env.user.partner_id - currency = pricelist.currency_id - website_company = ( - website.company_id - if website and getattr(website, "company_id", False) - else False - ) - company = website_company 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() - if website and getattr(website, "fiscal_position_id", False) - else env["account.fiscal.position"] - ) - 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, request_obj=None): - product_price_info = {} - - def _tax_included_default(product_record): - try: - req = request_obj or request - return req.website.show_line_subtotals_tax_selection != "tax_excluded" - except RuntimeError: - website = product_record.env["website"].get_current_website() - if not website: - return True - return website.show_line_subtotals_tax_selection != "tax_excluded" - - for product in products: - product_variant = ( - product.product_variant_ids[0] if product.product_variant_ids else False - ) - if product_variant and pricelist: - try: - try: - req = request_obj or request - partner = req.env.user.partner_id - except RuntimeError: - partner = product_variant.env.user.partner_id - - pricing = _get_pricing_info( - self, - product_variant, - pricelist, - quantity=1.0, - partner=partner, - request_obj=request_obj, - ) - 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": _tax_included_default(product), - } - 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": _tax_included_default(product), - } - return product_price_info - - -def _get_product_supplier_info(self, products): - 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, request_obj=None -): - if not delivery_product: - return 5.74 - try: - base_price = float(delivery_product.list_price or 0.0) - try: - req = request_obj or request - website = req.website - partner = req.env.user.partner_id - company = ( - website.company_id or delivery_product.company_id or req.env.company - ) - except RuntimeError: - env = delivery_product.env - website = env["website"].get_current_website() - partner = env.user.partner_id - company = website.company_id or delivery_product.company_id or env.company - - product_taxes = delivery_product.sudo().taxes_id._filter_taxes_by_company( - company - ) - fiscal_position = ( - website.fiscal_position_id.sudo() - if website and getattr(website, "fiscal_position_id", False) - else delivery_product.env["account.fiscal.position"] - ) - taxes = ( - fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes - ) - - if not taxes: - return base_price - - 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) diff --git a/website_sale_aplicoop/controllers/website_sale_products.py b/website_sale_aplicoop/controllers/website_sale_products.py deleted file mode 100644 index ac45220..0000000 --- a/website_sale_aplicoop/controllers/website_sale_products.py +++ /dev/null @@ -1,157 +0,0 @@ -import logging - -from odoo.http import request - -_logger = logging.getLogger(__name__) - - -def _build_category_hierarchy(self, categories): - if not categories: - return [] - category_map = {} - for cat in categories: - category_map[cat.id] = { - "id": cat.id, - "name": cat.name, - "sequence": cat.sequence, - "parent_id": cat.parent_id.id if cat.parent_id else None, - "children": [], - } - roots = [] - for _cat_id, cat_info in category_map.items(): - parent_id = cat_info["parent_id"] - if parent_id is None or parent_id not in category_map: - roots.append(cat_info) - else: - category_map[parent_id]["children"].append(cat_info) - - def sort_hierarchy(items): - items.sort(key=lambda x: (x["sequence"], x["name"])) - for item in items: - if item["children"]: - sort_hierarchy(item["children"]) - - sort_hierarchy(roots) - return roots - - -def _filter_published_tags(self, tags): - return tags.filtered(lambda t: getattr(t, "visible_on_ecommerce", True)) - - -def _filter_products(self, all_products, post, group_order): - search_query = post.get("search", "").strip() - category_filter = post.get("category", "0") - filtered_products = all_products - 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), - ) - - 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) - - products_in_categories = filtered_products.filtered( - lambda p: p.categ_id.id in all_category_ids - ) - 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)) - - 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 _collect_all_products_and_categories(self, group_order): - 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.sequence, c.name) - ) - - category_hierarchy = _build_category_hierarchy(self, available_categories) - return all_products, available_categories, category_hierarchy - - -def _prepare_products_maps(self, products, pricelist): - 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, - ) diff --git a/website_sale_aplicoop/controllers/website_sale_utils.py b/website_sale_aplicoop/controllers/website_sale_utils.py deleted file mode 100644 index d6fc70b..0000000 --- a/website_sale_aplicoop/controllers/website_sale_utils.py +++ /dev/null @@ -1,36 +0,0 @@ -import json -import logging - -from odoo.http import request - -_logger = logging.getLogger(__name__) - - -def _decode_json_body(self, request_obj=None): - req = request_obj or request - if not req.httprequest.data: - raise ValueError("No data provided") - raw_data = req.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): - req = getattr(self, "_request", None) or request - return req.make_response( - json.dumps( - { - "error": req.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, - ) diff --git a/website_sale_aplicoop/controllers/website_sale_validators.py b/website_sale_aplicoop/controllers/website_sale_validators.py deleted file mode 100644 index 3a774b1..0000000 --- a/website_sale_aplicoop/controllers/website_sale_validators.py +++ /dev/null @@ -1,243 +0,0 @@ -import logging - -from odoo.http import request - -_logger = logging.getLogger(__name__) - - -def _to_bool(self, value): - 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 _validate_user_group_access(self, group_order, current_user): - partner = current_user.partner_id - if not partner or not group_order: - raise ValueError("User is not a member of any consumer group in this order") - 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( - "_validate_user_group_access: user %s (%s) not member of any consumer group in order %s", - current_user.name, - current_user.id, - group_order.id, - ) - raise ValueError("User is not a member of any consumer group in this order") - - -def _get_consumer_group_for_user(self, group_order, current_user): - 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", - current_user.name, - current_user.id, - group_order.id, - ) - return False - - -def _get_salesperson_for_order(self, partner): - if partner.user_id and not partner.user_id._is_public(): - return partner.user_id - commercial_partner = partner.commercial_partner_id - if commercial_partner.user_id and not commercial_partner.user_id._is_public(): - return commercial_partner.user_id - return False - - -def _find_recent_draft_order(self, partner_id, group_order, request_obj=None): - req = request_obj or request - from datetime import datetime - from datetime import timedelta - - 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 ( - req.env["sale.order"].sudo().search(domain, order="create_date desc", limit=1) - ) - - -def _validate_confirm_request(self, data, request_obj=None): - req = request_obj or request - 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 - - group_order = req.env["group.order"].sudo().browse(order_id) - if not group_order.exists(): - raise ValueError(f"Order {order_id} not found") - if group_order.state != "open": - raise ValueError("Order is not available (not in open state)") - current_user = req.env.user - if not current_user.partner_id: - raise ValueError("User has no associated partner") - _validate_user_group_access(self, group_order, current_user) - items = data.get("items", []) - if not items: - raise ValueError("No items in cart") - _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, request_obj=None): - req = request_obj or request - 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 - group_order = req.env["group.order"].sudo().browse(order_id) - if not group_order.exists(): - raise ValueError(f"Order {order_id} not found") - current_user = req.env.user - if not current_user.partner_id: - raise ValueError("User has no associated partner") - _validate_user_group_access(self, group_order, current_user) - items = data.get("items", []) - if not items: - raise ValueError("No items in cart") - 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, request_obj=None): - req = request_obj or request - 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 - group_order = req.env["group.order"].sudo().browse(order_id) - if not group_order.exists(): - raise ValueError(f"Order {order_id} not found") - if group_order.state != "open": - raise ValueError(f"Order is {group_order.state}") - current_user = req.env.user - if not current_user.partner_id: - raise ValueError("User has no associated partner") - _validate_user_group_access(self, group_order, current_user) - items = data.get("items", []) - if not items: - raise ValueError("No items in cart") - is_delivery = _to_bool(self, 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 _validate_items_for_group_order(self, items, group_order, request_obj=None): - req = request_obj or request - if not items: - return { - "available_items": [], - "unavailable_items": [], - "unavailable_products": set(), - "warning_message": "", - } - try: - available_products = req.env["group.order"]._get_products_for_group_order( - group_order.id - ) - available_product_ids = set(available_products.ids) - except Exception as e: - _logger.error( - "Error getting available products for group_order %d: %s", group_order.id, e - ) - return { - "available_items": items, - "unavailable_items": [], - "unavailable_products": set(), - "warning_message": "", - } - - available_items = [] - unavailable_items = [] - unavailable_product_ids = set() - for item in items: - product_id = item.get("product_id") - if product_id in available_product_ids: - available_items.append(item) - else: - unavailable_items.append(item) - unavailable_product_ids.add(product_id) - - warning_message = "" - if unavailable_items: - unavailable_names = [ - item.get("product_name", "Unknown") for item in unavailable_items - ] - warning_message = req.env._( - "%(count)d product(s) from your saved order are no longer available in this group order: %(names)s. Only available products will be loaded.", - count=len(unavailable_items), - names=", ".join(unavailable_names), - ) - _logger.warning( - "load_order_from_history: %d unavailable items in group_order %d (products: %s)", - len(unavailable_items), - group_order.id, - unavailable_product_ids, - ) - - return { - "available_items": available_items, - "unavailable_items": unavailable_items, - "unavailable_products": unavailable_product_ids, - "warning_message": warning_message, - } diff --git a/website_sale_aplicoop/migrations/18.0.1.0.3/post-migrate.py b/website_sale_aplicoop/migrations/18.0.1.0.3/post-migrate.py deleted file mode 100644 index 43b06d2..0000000 --- a/website_sale_aplicoop/migrations/18.0.1.0.3/post-migrate.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Remove legacy pickup_slot_id column from sale_order. - -This migration drops the unused pickup_slot_id column which used to store a -snapshot of the assigned slot on each sale.order. We no longer persist that -reference; keep a human readable `pickup_slot_label` instead. -""" - -import logging - -_logger = logging.getLogger(__name__) - - -def migrate(cr, version): - # Raw SQL is used to ensure the column is dropped even if foreign key - # constraints exist. We try to drop the FK constraint first and then the - # column. Use IF EXISTS to avoid errors on already-migrated DBs. - try: - # Try dropping common FK constraint name (Postgres naming) - cr.execute(""" - DO $$ - BEGIN - IF EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conrelid = 'sale_order'::regclass - AND conname = 'sale_order_pickup_slot_id_fkey' - ) THEN - ALTER TABLE sale_order DROP CONSTRAINT sale_order_pickup_slot_id_fkey; - END IF; - END$$; - """) - except Exception as exc: # pragma: no cover - DB-level migration safeguard - # Not critical; constraint may have different name or not exist - _logger.debug( - "Could not drop FK constraint for sale_order.pickup_slot_id: %s", exc - ) - - try: - cr.execute("ALTER TABLE sale_order DROP COLUMN IF EXISTS pickup_slot_id;") - except Exception as exc: # pragma: no cover - DB-level migration safeguard - # If the column cannot be dropped (e.g. referenced elsewhere), log and - # continue; DB admins can drop manually if needed. - _logger.warning("Could not drop column sale_order.pickup_slot_id: %s", exc) diff --git a/website_sale_aplicoop/migrations/18.0.1.9.0/pre-migrate.py b/website_sale_aplicoop/migrations/18.0.1.9.0/pre-migrate.py deleted file mode 100644 index 9c7071d..0000000 --- a/website_sale_aplicoop/migrations/18.0.1.9.0/pre-migrate.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Add sequence field to product.category. - -Ensures the sequence column exists with a default of 10 for all existing rows. -The ORM will also handle this on upgrade, but we add it explicitly so the -column is present before any post-install logic runs. -""" - - -def migrate(cr, version): - cr.execute(""" - ALTER TABLE product_category - ADD COLUMN IF NOT EXISTS sequence INTEGER NOT NULL DEFAULT 10; - """) diff --git a/website_sale_aplicoop/models/__init__.py b/website_sale_aplicoop/models/__init__.py index 0a00ead..b94d3ea 100644 --- a/website_sale_aplicoop/models/__init__.py +++ b/website_sale_aplicoop/models/__init__.py @@ -1,9 +1,7 @@ -from . import group_order # noqa: F401 -from . import group_order_slot # noqa: F401 -from . import product_category_extension # noqa: F401 -from . import product_extension # noqa: F401 -from . import res_config_settings # noqa: F401 -from . import res_partner_extension # noqa: F401 -from . import sale_order_extension # noqa: F401 -from . import stock_picking_extension # noqa: F401 -from . import js_translations # noqa: F401 +from . import group_order +from . import product_extension +from . import res_config_settings +from . import res_partner_extension +from . import sale_order_extension +from . import stock_picking_extension +from . import js_translations diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py index 0fdcd52..e0f4250 100644 --- a/website_sale_aplicoop/models/group_order.py +++ b/website_sale_aplicoop/models/group_order.py @@ -10,9 +10,6 @@ from odoo import models from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) -# Pylint: explicit 'string' attributes are intentional for readable labels in views. -# Some linters flag these as redundant; disable that specific check here. -# pylint: disable=attribute-string-redundant class GroupOrder(models.Model): @@ -515,149 +512,16 @@ class GroupOrder(models.Model): return products_page, total_count, has_next - # === Pickup slots helpers === - pickup_slot_ids = fields.One2many( - "group.order.slot", - "group_order_id", - string="Pickup slots", - help="Different pickup time slots available for this order (weekday + time)", - tracking=True, - ) - - pickup_slots_count = fields.Integer( - compute="_compute_pickup_slots_count", - store=False, - help="Number of pickup slots configured for this order", - ) - - next_pickup_slot_id = fields.Many2one( - "group.order.slot", - string="Next Pickup Slot", - compute="_compute_next_pickup_slot", - store=True, - help="The pickup slot assigned for the next cycle (computed)", - ) - - next_pickup_datetime = fields.Datetime( - string="Next Pickup Datetime", - compute="_compute_next_pickup_slot", - store=True, - help="Datetime of the next pickup occurrence for the selected slot", - ) - - @api.depends("pickup_slot_ids") - def _compute_pickup_slots_count(self): - """Simple count of configured slots for quick UI badges.""" - for record in self: - record.pickup_slots_count = len(record.pickup_slot_ids or []) - - @api.depends( - "pickup_slot_ids", - "pickup_slot_ids.start_hour", - "pickup_slot_ids.weekday", - "cutoff_date", - "start_date", - ) - def _compute_next_pickup_slot(self): - """Compute the next pickup slot and its concrete datetime. - - Rules: - - If slots are configured, compute for each active slot the next - occurrence (date + start_hour) strictly AFTER the reference date - (cutoff_date if present, otherwise start_date or today). - - Select the slot whose occurrence datetime is the soonest (minimum). - - If no slots are configured, leave fields empty (fallback handled - by existing pickup_day logic). - """ - from datetime import datetime - from datetime import time - - for record in self: - record.next_pickup_slot_id = False - record.next_pickup_datetime = False - - slots = record.pickup_slot_ids.filtered(lambda s: s.active) - if not slots: - continue - - # Determine reference date (use cutoff_date if present) - if record.cutoff_date: - reference_date = record.cutoff_date - else: - today = datetime.now().date() - if record.start_date and record.start_date < today: - reference_date = today - else: - reference_date = record.start_date or today - - candidate_datetimes = [] - for slot in slots: - try: - slot_weekday = int(slot.weekday) - except Exception: - # Skip malformed slot - continue - - current_weekday = reference_date.weekday() - days_ahead = slot_weekday - current_weekday - # Ensure NEXT occurrence AFTER reference (not same-day) - if days_ahead <= 0: - days_ahead += 7 - - target_date = reference_date + timedelta(days=days_ahead) - - # Convert start_hour float to time - sh = float(slot.start_hour or 0.0) - sh_h = int(sh) - sh_m = int(round((sh - sh_h) * 60)) - try: - slot_dt = datetime.combine(target_date, time(sh_h, sh_m)) - except Exception: - # Fallback to date-only - slot_dt = datetime.combine(target_date, time(0, 0)) - - candidate_datetimes.append((slot_dt, slot)) - - if not candidate_datetimes: - continue - - # Choose earliest datetime - candidate_datetimes.sort(key=lambda x: x[0]) - chosen_dt, chosen_slot = candidate_datetimes[0] - - # Assign results (store datetime as timezone-naive; Odoo will convert) - record.next_pickup_slot_id = chosen_slot - record.next_pickup_datetime = chosen_dt - - @api.depends("cutoff_date", "pickup_day", "pickup_slot_ids", "next_pickup_datetime") + @api.depends("cutoff_date", "pickup_day") def _compute_pickup_date(self): - """Compute pickup date. + """Compute pickup date as the first occurrence of pickup_day AFTER cutoff_date. - If pickup slots are configured, derive `pickup_date` from the computed - `next_pickup_datetime`. Otherwise, fall back to the previous - single-day `pickup_day` behavior. + This ensures pickup always comes after cutoff, maintaining logical order. """ from datetime import datetime _logger.info("_compute_pickup_date called for %d records", len(self)) for record in self: - # If slots exist, prefer the computed next_pickup_datetime - if record.pickup_slot_ids: - if record.next_pickup_datetime: - try: - dt = ( - fields.Datetime.to_datetime(record.next_pickup_datetime) - if isinstance(record.next_pickup_datetime, str) - else record.next_pickup_datetime - ) - record.pickup_date = dt.date() - except Exception: - record.pickup_date = None - else: - record.pickup_date = None - continue - - # Fallback: original single pickup_day logic if not record.pickup_day: record.pickup_date = None continue @@ -1018,7 +882,7 @@ class GroupOrder(models.Model): ) def _create_picking_batches_for_sale_orders(self, sale_orders): - """Create stock.picking.batch grouped by picking type for this group order. + """Create stock.picking.batch grouped by consumer_group_id. Args: sale_orders: Recordset of confirmed sale.order @@ -1026,45 +890,47 @@ class GroupOrder(models.Model): self.ensure_one() StockPickingBatch = self.env["stock.picking.batch"].sudo() - # Create batches per group order, not per consumer group. - # If multiple picking types exist, keep one batch per picking type. - grouped_pickings = {} - pickings = sale_orders.picking_ids.filtered( - lambda p: p.state not in ("done", "cancel") and not p.batch_id - ) - for picking in pickings: - grouped_pickings.setdefault( - picking.picking_type_id.id, self.env["stock.picking"] - ) - grouped_pickings[picking.picking_type_id.id] |= picking + # Group sale orders by consumer_group_id + groups = {} + for so in sale_orders: + group_id = so.consumer_group_id.id or False + if group_id not in groups: + groups[group_id] = self.env["sale.order"] + groups[group_id] |= so - scheduled_date = None - if self.pickup_date: - scheduled_date = fields.Datetime.to_datetime(self.pickup_date) - elif self.delivery_date: - scheduled_date = fields.Datetime.to_datetime( - self.delivery_date - timedelta(days=1) + for consumer_group_id, group_sale_orders in groups.items(): + # Get pickings without batch + pickings = group_sale_orders.picking_ids.filtered( + lambda p: p.state not in ("done", "cancel") and not p.batch_id ) - for picking_type_id, pickings in grouped_pickings.items(): if not pickings: continue - batch_desc = self.name + # Get consumer group name for batch description + consumer_group = self.env["res.partner"].browse(consumer_group_id) + batch_desc = ( + f"{self.name} - {consumer_group.name}" if consumer_group else self.name + ) + + # Create the batch batch = StockPickingBatch.create( { "description": batch_desc, "company_id": self.company_id.id, - "picking_type_id": picking_type_id, - "scheduled_date": scheduled_date, + "picking_type_id": pickings[0].picking_type_id.id, + "scheduled_date": self.pickup_date, } ) + # Assign pickings to the batch pickings.write({"batch_id": batch.id}) _logger.info( - "Cron: Created batch %s with %d pickings for group order %s", + "Cron: Created batch %s with %d pickings for group order %s, " + "consumer group %s", batch.name, len(pickings), self.name, + consumer_group.name if consumer_group else "N/A", ) diff --git a/website_sale_aplicoop/models/group_order_slot.py b/website_sale_aplicoop/models/group_order_slot.py deleted file mode 100644 index b261aaa..0000000 --- a/website_sale_aplicoop/models/group_order_slot.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2026 Criptomart -# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) - -from odoo import fields -from odoo import models - -# Pylint: explicit 'string' attributes are intentional for readable labels in views. -# Some linters flag these as redundant; disable that specific check here. -# pylint: disable=attribute-string-redundant - - -class GroupOrderSlot(models.Model): - _name = "group.order.slot" - _description = "Pickup slot for a Consumer Group Order" - _order = "sequence, weekday, start_hour" - - group_order_id = fields.Many2one( - "group.order", - string="Group Order", - required=True, - ondelete="cascade", - help="Consumer group order this slot belongs to", - ) - - weekday = fields.Selection( - [(str(i), str(i)) for i in range(7)], - string="Weekday", - required=True, - help="Day of week for this slot (0=Monday)", - ) - - start_hour = fields.Float( - string="Start hour", - help="Start hour in decimal form, e.g. 9.5 = 09:30", - ) - - end_hour = fields.Float( - string="End hour", - help="End hour in decimal form, e.g. 14.25 = 14:15", - ) - - label = fields.Char( - string="Label", - help="Human readable short label for the slot (optional)", - ) - - sequence = fields.Integer(string="Sequence", default=10) - - active = fields.Boolean(default=True) - - def _get_display_label(self): - """Return a fallback display label combining weekday and hours. - - This is a small helper used by views or when a specific `label` is - not provided. - """ - self.ensure_one() - if self.label: - return self.label - # Fallback: simple numeric representation - sh = "%02d:%02d" % ( - int(self.start_hour or 0), - int((self.start_hour or 0) % 1 * 60), - ) - eh = "%02d:%02d" % (int(self.end_hour or 0), int((self.end_hour or 0) % 1 * 60)) - return f"{self.weekday} {sh}-{eh}" diff --git a/website_sale_aplicoop/models/product_category_extension.py b/website_sale_aplicoop/models/product_category_extension.py deleted file mode 100644 index e562b10..0000000 --- a/website_sale_aplicoop/models/product_category_extension.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright 2026 Criptomart -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). - -from odoo import fields -from odoo import models - - -class ProductCategory(models.Model): - _inherit = "product.category" - _order = "sequence, name" - - sequence = fields.Integer(default=10) diff --git a/website_sale_aplicoop/models/sale_order_extension.py b/website_sale_aplicoop/models/sale_order_extension.py index 739b300..93dcbf2 100644 --- a/website_sale_aplicoop/models/sale_order_extension.py +++ b/website_sale_aplicoop/models/sale_order_extension.py @@ -1,19 +1,9 @@ # Copyright 2025 Criptomart # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -import logging - -from odoo import api from odoo import fields from odoo import models -# Pylint: the explicit 'string' parameter is intentional for clarity in views. -# Some fields may trigger 'attribute-string-redundant' warnings; silence them -# locally where appropriate. -# pylint: disable=attribute-string-redundant - -_logger = logging.getLogger(__name__) - class SaleOrder(models.Model): _inherit = "sale.order" @@ -50,108 +40,11 @@ class SaleOrder(models.Model): help="Pickup/delivery date", ) - pickup_slot_label = fields.Char( - string="Pickup Slot Label", - compute="_compute_pickup_slot_label", - store=True, - readonly=True, - ) - home_delivery = fields.Boolean( default=False, help="Whether this order includes home delivery", ) - @api.depends( - "group_order_id", - "group_order_id.next_pickup_slot_id", - "group_order_id.next_pickup_slot_id.label", - "group_order_id.next_pickup_slot_id.start_hour", - "group_order_id.next_pickup_slot_id.end_hour", - "pickup_date", - "pickup_day", - ) - def _compute_pickup_slot_label(self): - """Compute a human readable label for the pickup information. - - Priority: - 1. Use the group order's current `next_pickup_slot_id` if available - 2. Fallback to legacy `pickup_day` / `pickup_date` fields - Note: we deliberately do NOT store a Many2one reference to the - slot on the sale.order anymore — we compute the label dynamically - from the related group order to avoid persisting slot IDs. - """ - for order in self: - slot = False - if order.group_order_id and order.group_order_id.next_pickup_slot_id: - slot = order.group_order_id.next_pickup_slot_id - - if slot: - if slot.label: - label = slot.label - else: - sh = float(slot.start_hour or 0.0) - eh = float(slot.end_hour or 0.0) - sh_h = int(sh) - sh_m = int(round((sh - sh_h) * 60)) - eh_h = int(eh) - eh_m = int(round((eh - eh_h) * 60)) - label = f"{sh_h:02d}:{sh_m:02d}-{eh_h:02d}:{eh_m:02d}" - - if order.pickup_date: - try: - date_str = ( - order.pickup_date.strftime("%d/%m/%Y") - if hasattr(order.pickup_date, "strftime") - else str(order.pickup_date) - ) - label = f"{label} ({date_str})" - except ( - Exception - ) as exc: # log format errors, but don't break compute - _logger.debug( - "_compute_pickup_slot_label: failed to format pickup_date for order %s: %s", - order.id if order and order.id else None, - exc, - exc_info=True, - ) - order.pickup_slot_label = label - else: - # Fallback to single-day fields - if order.pickup_day: - try: - day_map = dict(order._get_pickup_day_selection()) - day_name = day_map.get(order.pickup_day, order.pickup_day) - except Exception as exc: - _logger.debug( - "_compute_pickup_slot_label: failed to map pickup_day for order %s: %s", - order.id if order and order.id else None, - exc, - exc_info=True, - ) - day_name = order.pickup_day - - if order.pickup_date: - try: - date_str = ( - order.pickup_date.strftime("%d/%m/%Y") - if hasattr(order.pickup_date, "strftime") - else str(order.pickup_date) - ) - order.pickup_slot_label = f"{day_name} ({date_str})" - except Exception as exc: - _logger.debug( - "_compute_pickup_slot_label: failed to format pickup_date (fallback) for order %s: %s", - order.id if order and order.id else None, - exc, - exc_info=True, - ) - order.pickup_slot_label = day_name - else: - order.pickup_slot_label = day_name - else: - order.pickup_slot_label = False - def _get_name_portal_content_view(self): """Override to return custom portal content template with group order info. diff --git a/website_sale_aplicoop/models/stock_picking_extension.py b/website_sale_aplicoop/models/stock_picking_extension.py index 735c986..94e7295 100644 --- a/website_sale_aplicoop/models/stock_picking_extension.py +++ b/website_sale_aplicoop/models/stock_picking_extension.py @@ -33,14 +33,6 @@ class StockPicking(models.Model): help="Pickup/delivery date from sale order", ) - pickup_slot_label = fields.Char( - related="sale_id.pickup_slot_label", - string="Pickup Slot", - store=True, - readonly=True, - help="Human readable pickup slot label from the related sale order", - ) - consumer_group_id = fields.Many2one( "res.partner", related="sale_id.consumer_group_id", diff --git a/website_sale_aplicoop/static/src/css/components/product-card.css b/website_sale_aplicoop/static/src/css/components/product-card.css index fd36a45..c6eef5c 100644 --- a/website_sale_aplicoop/static/src/css/components/product-card.css +++ b/website_sale_aplicoop/static/src/css/components/product-card.css @@ -5,44 +5,38 @@ */ .product-card { - background-color: #fff; + background-color: white; border: 1px solid #e0e0e0; - border-radius: 12px; + border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - transition: box-shadow 0.3s, transform 0.2s; + transition: all 0.3s ease; display: flex; flex-direction: column; - padding: 0.5rem; + padding: 0.5rem 0.5rem 0.5rem 0.5rem; height: 100%; overflow: hidden; - outline: none; } -.product-card:hover, +.product-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.12); +} + .product-card:focus-within { - transform: translateY(-4px) scale(1.01); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.13); - outline: 2px solid var(--primary-color, #007bff); + outline: 3px solid var(--primary-color); outline-offset: 2px; } .product-card .product-image { - height: 120px; - width: 100%; + height: 150px; object-fit: cover; - border-radius: 8px 8px 0 0; - background: #f3f3f3; - display: block; } .product-img-cover { - max-height: 120px; - width: 100%; + max-height: 160px; object-fit: cover; - border-radius: 8px 8px 0 0; - box-shadow: 0 2px 8px rgba(40, 39, 39, 0.09); - background: #f3f3f3; - display: block; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(40, 39, 39, 0.9); } .product-card .card-body { @@ -50,168 +44,105 @@ flex-direction: column; height: 100%; flex-grow: 1; - padding: 0.6rem 0.7rem 0.7rem 0.7rem; + padding: 0.75rem; position: relative; - background: linear-gradient(135deg, rgba(0, 123, 255, 0.07) 0%, rgba(0, 123, 255, 0.04) 100%); - transition: background 0.3s; + background: linear-gradient(135deg, rgba(0, 123, 255, 0.12) 0%, rgba(0, 123, 255, 0.08) 100%); + transition: all 0.3s ease; } -.product-card:hover .card-body, -.product-card:focus-within .card-body { +.product-card:hover .card-body { background: linear-gradient( 135deg, - rgba(108, 117, 125, 0.13) 0%, - rgba(108, 117, 125, 0.09) 100% + rgba(108, 117, 125, 0.1) 0%, + rgba(108, 117, 125, 0.08) 100% ); } .product-card .card-title { flex-grow: 1; - margin: 0 0 0.15rem 0; + margin: 0; + margin-bottom: 0.2rem; min-height: auto; display: block; word-wrap: break-word; overflow-wrap: break-word; - font-size: 1.08rem !important; - line-height: 1.1; + font-size: 1.2rem !important; + line-height: 1; text-align: center; font-weight: 600; - color: #1a202c; - letter-spacing: 0.01em; + color: #2d3748; } .product-card .card-text { - margin-bottom: 0.12rem; + margin-bottom: 0.15rem; text-align: center; - font-size: 1rem; } .product-card .card-text strong { display: block; - margin-bottom: 0.1rem; - font-size: 1.15rem; - color: #3b82f6; + margin-bottom: 0.15rem; + font-size: 1.2rem; + color: #667eea; } .product-card .product-supplier { text-align: center; color: #4a5568; font-weight: 400; - margin-bottom: 0.12rem; - font-size: 0.92rem !important; + margin-bottom: 0.15rem; + font-size: 0.9rem !important; } .product-tags { text-align: center; display: flex; flex-wrap: wrap; - gap: 0.18rem; + gap: 0.2rem; justify-content: center; font-weight: 400; - font-size: 1.1rem !important; + font-size: 1.4rem !important; margin: 0; padding: 0; } .badge-km { - background-color: var(--primary-color, #007bff) !important; - color: #fff !important; + background-color: var(--primary-color) !important; + color: white !important; font-weight: 600 !important; - padding: 0.18rem 0.32rem !important; - font-size: 0.68rem !important; - border-radius: 0.22rem; + padding: 0.2rem !important; + font-size: 0.6rem !important; + border-radius: 0.2rem; display: inline-block; - border: 1px solid #007bff; + border: 1px solid; white-space: nowrap; - margin-right: 0.08rem; - margin-bottom: 0.08rem; + margin-right: 0.1rem; + margin-bottom: 0.1rem; } .card-body p.card-text { text-align: center; - margin-bottom: 0.6rem; - min-height: 1.7rem; + margin-bottom: 0.8rem; + min-height: 2rem; display: flex; align-items: center; justify-content: center; - background-color: var(--primary-color, #007bff); - color: #fff; - border-radius: 0.18rem; - font-size: 1.05rem; + background-color: var(--primary-color); + color: white; } .card-body p.card-text strong { display: inline; - font-size: 1.18rem !important; - color: var(--primary-color, #007bff); + font-size: 1.4rem !important; + color: var(--primary-color); margin-bottom: 0; white-space: nowrap; } .product-img-fixed { object-fit: cover; - height: 120px; - width: 100%; - border-radius: 8px 8px 0 0; - background: #f3f3f3; - display: block; + height: 100px; } .product-img-placeholder { - height: 120px; - width: 100%; - object-fit: cover; - border-radius: 8px 8px 0 0; - background: #f3f3f3 - url('data:image/svg+xml;utf8,Sin imagen') - no-repeat center center; - display: block; -} - -/* Responsive: mejorar altura y espaciado en móvil */ -@media (max-width: 600px) { - .product-card { - padding: 0.25rem; - border-radius: 8px; - } - .product-card .product-image, - .product-img-cover, - .product-img-fixed, - .product-img-placeholder { - height: 70px; - max-height: 70px; - min-height: 70px; - border-radius: 6px 6px 0 0; - } - .product-card .card-body { - padding: 0.4rem 0.4rem 0.5rem 0.4rem; - } - .product-card .card-title { - font-size: 0.98rem !important; - margin-bottom: 0.08rem; - } - .product-card .card-text { - font-size: 0.92rem; - } - .badge-km { - font-size: 0.58rem !important; - padding: 0.13rem 0.22rem !important; - } - .product-tags { - font-size: 0.95rem !important; - } - .card-body p.card-text { - min-height: 1.1rem; - font-size: 0.95rem; - margin-bottom: 0.3rem; - } - .product-card .product-supplier { - font-size: 0.82rem !important; - } -} - -/* Accesibilidad: focus visible */ -.product-card:focus-visible { - outline: 2.5px solid var(--primary-color, #007bff); - outline-offset: 2px; + height: 100px; } diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js index ad4a311..9e194a6 100644 --- a/website_sale_aplicoop/static/src/js/website_sale.js +++ b/website_sale_aplicoop/static/src/js/website_sale.js @@ -180,44 +180,9 @@ } } - // If backend provided a human-readable pickup slot label, - // display it to the user or update a dedicated element if present. - if (data.pickup_slot_label) { - var slotLabel = data.pickup_slot_label; - var slotElement = document.getElementById("pickup-slot-label"); - if (slotElement) { - slotElement.textContent = slotLabel; - console.log( - "Auto-loaded pickup_slot_label into element:", - slotLabel - ); - } else { - // Gentle info notification so user sees the pickup info - self._showNotification("Pickup: " + slotLabel, "info", 3000); - } - } - // Update display self._updateCartDisplay(); - // Show pickup slot label if provided by backend - if (data.pickup_slot_label) { - var slotEl = document.getElementById("pickup-slot-label"); - if (slotEl) { - slotEl.textContent = data.pickup_slot_label; - console.log( - "Restored pickup_slot_label into element:", - data.pickup_slot_label - ); - } else { - self._showNotification( - "Pickup: " + data.pickup_slot_label, - "info", - 3000 - ); - } - } - console.log("Auto-loaded " + items.length + " items from draft"); // Show a subtle notification var labels = self._getLabels(); @@ -257,16 +222,12 @@ var pickupDayKey = "load_from_history_pickup_day_" + this.orderId; var pickupDateKey = "load_from_history_pickup_date_" + this.orderId; var homeDeliveryKey = "load_from_history_home_delivery_" + this.orderId; - var pickupSlotLabelKey = "load_from_history_pickup_slot_label_" + this.orderId; - var warningKey = "load_from_history_warning_" + this.orderId; var itemsJson = sessionStorage.getItem(storageKey); var orderName = sessionStorage.getItem(orderNameKey); var pickupDay = sessionStorage.getItem(pickupDayKey); var pickupDate = sessionStorage.getItem(pickupDateKey); var homeDelivery = sessionStorage.getItem(homeDeliveryKey) === "true"; - var pickupSlotLabel = sessionStorage.getItem(pickupSlotLabelKey); - var warningMessage = sessionStorage.getItem(warningKey); console.log("DEBUG: _loadFromHistory called for orderId:", this.orderId); console.log("DEBUG: sessionStorageKey:", storageKey); @@ -279,7 +240,6 @@ homeDelivery, "(empty means different group order)" ); - console.log("DEBUG: warningMessage:", warningMessage); if (!itemsJson || itemsJson === "[object Object]") { console.log("No valid items from history found in sessionStorage"); @@ -288,7 +248,6 @@ sessionStorage.removeItem(pickupDayKey); sessionStorage.removeItem(pickupDateKey); sessionStorage.removeItem(homeDeliveryKey); - sessionStorage.removeItem(warningKey); return; } @@ -310,7 +269,6 @@ sessionStorage.removeItem(pickupDayKey); sessionStorage.removeItem(pickupDateKey); sessionStorage.removeItem(homeDeliveryKey); - sessionStorage.removeItem(warningKey); return; } @@ -389,30 +347,14 @@ if (orderName) { message += " - " + orderName; } - if (pickupSlotLabel) { - message += - " — " + - (self.labels && self.labels.pickup_label - ? self.labels.pickup_label + ": " - : "Pickup: ") + - pickupSlotLabel; - } this._showNotification(message, "success", 3000); - // Show warning if some products were unavailable - if (warningMessage) { - console.log("Showing warning about unavailable products:", warningMessage); - this._showNotification(warningMessage, "warning", 5000); - } - // Clear sessionStorage sessionStorage.removeItem(storageKey); sessionStorage.removeItem(orderNameKey); sessionStorage.removeItem(pickupDayKey); sessionStorage.removeItem(pickupDateKey); sessionStorage.removeItem(homeDeliveryKey); - sessionStorage.removeItem(pickupSlotLabelKey); - sessionStorage.removeItem(warningKey); } catch (e) { console.error("Error loading from history:", e); console.error("itemsJson was:", itemsJson); @@ -493,32 +435,8 @@ } try { var data = JSON.parse(xhr.responseText || "{}"); - // Clear cart if order is closed, action requests clearance, or cutoff already passed - var shouldClear = false; - if (data) { - if (data.is_open === false) { - shouldClear = true; - } - if (data.action && data.action === "clear_cart") { - shouldClear = true; - } - if (data.cutoff_passed === true) { - shouldClear = true; - } - } - if (shouldClear) { - console.log( - "[groupOrderShop] check-status: clearing cart (reason:", - data, - ")" - ); + if (data && data.is_open === false) { self._clearCurrentOrderCartSilently(); - // Update on-screen cart if visible - try { - self._updateCartDisplay(); - } catch (err) { - console.warn("_updateCartDisplay failed after clearing cart:", err); - } } } catch (e) { console.warn("[groupOrderShop] check-status parse error", e); diff --git a/website_sale_aplicoop/tests/test_cron_picking_batch.py b/website_sale_aplicoop/tests/test_cron_picking_batch.py index 152be12..ae04036 100644 --- a/website_sale_aplicoop/tests/test_cron_picking_batch.py +++ b/website_sale_aplicoop/tests/test_cron_picking_batch.py @@ -227,8 +227,8 @@ class TestCronPickingBatch(TransactionCase): "Cron should snapshot and preserve the current cycle pickup_date when confirming", ) - def test_cron_creates_single_picking_batch_for_group_order(self): - """Test that cron creates a single picking batch for the whole group order.""" + def test_cron_creates_picking_batch_per_consumer_group(self): + """Test that cron creates separate picking batches per consumer group.""" # Create group order with cutoff yesterday (past) group_order = self._create_group_order(cutoff_in_past=True) @@ -247,30 +247,39 @@ class TestCronPickingBatch(TransactionCase): self.assertTrue(so1.picking_ids, "Sale order 1 should have pickings") self.assertTrue(so2.picking_ids, "Sale order 2 should have pickings") - # Check that all pickings share the same batch + # Check that pickings have batch_id assigned + for picking in so1.picking_ids: + self.assertTrue( + picking.batch_id, + "Picking from SO1 should be assigned to a batch", + ) + + for picking in so2.picking_ids: + self.assertTrue( + picking.batch_id, + "Picking from SO2 should be assigned to a batch", + ) + + # Check that batches are different (one per consumer group) batch_1 = so1.picking_ids[0].batch_id batch_2 = so2.picking_ids[0].batch_id - self.assertEqual( + self.assertNotEqual( batch_1.id, batch_2.id, - "Different consumer groups in the same group order should share one batch", + "Different consumer groups should have different batches", ) - # Check that there is only one batch record created - self.assertEqual( - self.env["stock.picking.batch"].search_count( - [("description", "=", group_order.name)] - ), - 1, - "Only one batch should be created for a single group order", - ) - - # Check batch description uses the group order name only - self.assertEqual( + # Check batch descriptions contain consumer group names + self.assertIn( + self.consumer_group_1.name, batch_1.description, - group_order.name, - "Batch description should be the group order name", + "Batch 1 description should include consumer group 1 name", + ) + self.assertIn( + self.consumer_group_2.name, + batch_2.description, + "Batch 2 description should include consumer group 2 name", ) def test_cron_same_consumer_group_same_batch(self): @@ -333,10 +342,10 @@ class TestCronPickingBatch(TransactionCase): self.assertTrue(so.picking_ids, "Sale order should have pickings") batch = so.picking_ids[0].batch_id self.assertTrue(batch, "Picking should have a batch") - self.assertEqual( - batch.scheduled_date.date(), - group_order.pickup_date, - "Batch scheduled_date should be the pickup date (day before delivery)", + # scheduled_date should be set (not False/None) + self.assertTrue( + batch.scheduled_date, + "Batch should have a scheduled_date set", ) def test_cron_does_not_duplicate_batches(self): @@ -355,18 +364,13 @@ class TestCronPickingBatch(TransactionCase): self.assertTrue(so.picking_ids, "Sale order should have pickings") batch_first = so.picking_ids[0].batch_id - batch_count_first = self.env["stock.picking.batch"].search_count( - [("description", "=", group_order.name)] - ) + batch_count_first = self.env["stock.picking.batch"].search_count([]) # Call second time group_order._confirm_linked_sale_orders() - so.invalidate_recordset() batch_second = so.picking_ids[0].batch_id - batch_count_second = self.env["stock.picking.batch"].search_count( - [("description", "=", group_order.name)] - ) + batch_count_second = self.env["stock.picking.batch"].search_count([]) # Should be same batch, no duplicates self.assertEqual( @@ -423,7 +427,7 @@ class TestCronPickingBatch(TransactionCase): def _patched_action_confirm(recordset): should_fail = any(so.name == "SO-FAIL" for so in recordset) if should_fail and not recordset.env.context.get("from_orderpoint"): - raise UserError() + raise UserError("Simulated stock route error") return original_action_confirm(recordset) with patch.object(SaleOrderClass, "action_confirm", _patched_action_confirm): diff --git a/website_sale_aplicoop/tests/test_multi_company.py b/website_sale_aplicoop/tests/test_multi_company.py index a1f9d68..22d9f86 100644 --- a/website_sale_aplicoop/tests/test_multi_company.py +++ b/website_sale_aplicoop/tests/test_multi_company.py @@ -15,41 +15,16 @@ class TestMultiCompanyGroupOrder(TransactionCase): super().setUp() # Crear dos compañías - company_model = self.env["res.company"] - - # Compatibilidad con esquemas legacy: columna NOT NULL presente sin campo ORM. - self.env.cr.execute(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'res_company' - AND column_name IN ('batch_summary_restriction_scope', 'batch_detailed_restriction_scope') - """) - existing_columns = {row[0] for row in self.env.cr.fetchall()} - if ( - "batch_summary_restriction_scope" in existing_columns - and "batch_summary_restriction_scope" not in company_model._fields - ): - self.env.cr.execute( - "ALTER TABLE res_company ALTER COLUMN batch_summary_restriction_scope SET DEFAULT 'processed'" - ) - if ( - "batch_detailed_restriction_scope" in existing_columns - and "batch_detailed_restriction_scope" not in company_model._fields - ): - self.env.cr.execute( - "ALTER TABLE res_company ALTER COLUMN batch_detailed_restriction_scope SET DEFAULT 'processed'" - ) - - def _company_vals(name): - vals = {"name": name} - if "batch_summary_restriction_scope" in company_model._fields: - vals["batch_summary_restriction_scope"] = "processed" - if "batch_detailed_restriction_scope" in company_model._fields: - vals["batch_detailed_restriction_scope"] = "processed" - return vals - - self.company1 = company_model.create(_company_vals("Company 1")) - self.company2 = company_model.create(_company_vals("Company 2")) + self.company1 = self.env["res.company"].create( + { + "name": "Company 1", + } + ) + self.company2 = self.env["res.company"].create( + { + "name": "Company 2", + } + ) # Crear grupos en diferentes compañías self.group1 = self.env["res.partner"].create( diff --git a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py index 20fcb06..f183c0a 100644 --- a/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py +++ b/website_sale_aplicoop/tests/test_phase3_confirm_eskaera.py @@ -23,7 +23,6 @@ from datetime import timedelta from types import SimpleNamespace from unittest.mock import patch -from odoo.exceptions import UserError from odoo.tests.common import TransactionCase from odoo.addons.website_sale_aplicoop.controllers.website_sale import ( @@ -35,11 +34,6 @@ REQUEST_PATCH_TARGET = ( ) -def _env_with_lang(env, lang): - """Return a cloned environment with language context set.""" - return env(context=dict(env.context, lang=lang)) - - def _make_json_response(data, headers=None, status=200): """Build a lightweight HTTP-like response object for controller tests.""" @@ -394,7 +388,7 @@ class TestProcessCartItems(TransactionCase): def test_process_cart_items_success(self): """Test successful cart item processing.""" - request_mock = _build_request_mock(_env_with_lang(self.env, "es_ES")) + request_mock = _build_request_mock(self.env.with_context(lang="es_ES")) items = [ { @@ -417,11 +411,11 @@ class TestProcessCartItems(TransactionCase): self.assertEqual(result[0][1], 0) self.assertIn("product_id", result[0][2]) self.assertEqual(result[0][2]["product_uom_qty"], 2) - self.assertGreater(result[0][2]["price_unit"], 0.0) + self.assertEqual(result[0][2]["price_unit"], 15.0) def test_process_cart_items_uses_list_price_fallback(self): """Test cart processing uses list_price when product_price is 0.""" - request_mock = _build_request_mock(_env_with_lang(self.env, "es_ES")) + request_mock = _build_request_mock(self.env.with_context(lang="es_ES")) items = [ { @@ -435,12 +429,12 @@ class TestProcessCartItems(TransactionCase): result = self.controller._process_cart_items(items, self.group_order) self.assertEqual(len(result), 1) - # Should produce a valid positive unit price - self.assertGreater(result[0][2]["price_unit"], 0.0) + # Should use product.list_price as fallback + self.assertEqual(result[0][2]["price_unit"], self.product1.list_price) def test_process_cart_items_skips_invalid_product(self): """Test cart processing skips non-existent products.""" - request_mock = _build_request_mock(_env_with_lang(self.env, "es_ES")) + request_mock = _build_request_mock(self.env.with_context(lang="es_ES")) items = [ { @@ -464,7 +458,7 @@ class TestProcessCartItems(TransactionCase): def test_process_cart_items_empty_after_filtering(self): """Test cart processing raises error when no valid items remain.""" - request_mock = _build_request_mock(_env_with_lang(self.env, "es_ES")) + request_mock = _build_request_mock(self.env.with_context(lang="es_ES")) items = [{"product_id": 99999, "quantity": 1, "product_price": 10.0}] @@ -476,10 +470,7 @@ class TestProcessCartItems(TransactionCase): def test_process_cart_items_translates_product_name(self): """Test cart processing uses translated product names.""" - request_mock = _build_request_mock(_env_with_lang(self.env, "eu_ES")) - - if "ir.translation" not in self.env: - self.skipTest("ir.translation model not available in this test registry") + request_mock = _build_request_mock(self.env.with_context(lang="eu_ES")) # Add translation for product name self.env["ir.translation"].create( @@ -549,43 +540,55 @@ class TestBuildConfirmationMessage(TransactionCase): } ) - def _build_confirmation_result( - self, lang="es_ES", sale_order=None, group_order=None, is_delivery=False - ): - request_mock = _build_request_mock(_env_with_lang(self.env, lang)) - with patch(REQUEST_PATCH_TARGET, request_mock): - try: - return self.controller._build_confirmation_message( - sale_order or self.sale_order, - group_order or self.group_order, - is_delivery=is_delivery, - ) - except UserError as err: - if "Invalid language code" in str(err): - self.skipTest(str(err)) - raise - - def test_build_confirmation_message_pickup(self): + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_pickup(self, mock_request): """Test confirmation message for pickup (not delivery).""" - result = self._build_confirmation_result(lang="es_ES", is_delivery=False) + mock_request.env = self.env.with_context(lang="es_ES") + + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) self.assertIn("message", result) self.assertIn("pickup_day", result) self.assertIn("pickup_date", result) self.assertIn("pickup_day_index", result) + + # Should contain "Thank you" text (or translation) + self.assertIn("Thank you", result["message"]) + + # Should contain order reference self.assertIn(self.sale_order.name, result["message"]) + + # Should have pickup day index self.assertEqual(result["pickup_day_index"], 5) - def test_build_confirmation_message_delivery(self): + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_delivery(self, mock_request): """Test confirmation message for home delivery.""" - result = self._build_confirmation_result(lang="es_ES", is_delivery=True) + mock_request.env = self.env.with_context(lang="es_ES") + + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=True + ) self.assertIn("message", result) + + # Should contain "Delivery date" label (or translation) + # and should use delivery_date, not pickup_date message = result["message"] self.assertIsNotNone(message) - def test_build_confirmation_message_no_dates(self): + # Delivery day should be next day after pickup (Saturday -> Sunday) + # pickup_day_index=5 (Saturday), delivery should be 6 (Sunday) + # Note: _get_day_names would need to be mocked for exact day name + + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_no_dates(self, mock_request): """Test confirmation message when no pickup date is set.""" + mock_request.env = self.env.with_context(lang="es_ES") + + # Create order without dates group_order_no_dates = self.env["group.order"].create( { "name": "Order No Dates", @@ -601,59 +604,126 @@ class TestBuildConfirmationMessage(TransactionCase): } ) - result = self._build_confirmation_result( - lang="es_ES", - sale_order=sale_order_no_dates, - group_order=group_order_no_dates, - is_delivery=False, + result = self.controller._build_confirmation_message( + sale_order_no_dates, group_order_no_dates, is_delivery=False ) + # Should still build message without dates self.assertIn("message", result) - self.assertIn(sale_order_no_dates.name, result["message"]) + self.assertIn("Thank you", result["message"]) + + # Date fields should be empty self.assertEqual(result["pickup_date"], "") - def test_build_confirmation_message_formats_date(self): + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_formats_date(self, mock_request): """Test confirmation message formats dates correctly (DD/MM/YYYY).""" - result = self._build_confirmation_result(lang="es_ES", is_delivery=False) + mock_request.env = self.env.with_context(lang="es_ES") + + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + # Should have date in DD/MM/YYYY format pickup_date_str = result["pickup_date"] self.assertIsNotNone(pickup_date_str) + + # Verify format with regex + date_pattern = r"\d{2}/\d{2}/\d{4}" self.assertRegex(pickup_date_str, date_pattern) - def test_build_confirmation_message_multilang_es(self): + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_multilang_es(self, mock_request): """Test confirmation message in Spanish (es_ES).""" - result = self._build_confirmation_result(lang="es_ES", is_delivery=False) - self.assertIsNotNone(result["message"]) + mock_request.env = self.env.with_context(lang="es_ES") - def test_build_confirmation_message_multilang_eu(self): + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + message = result["message"] + # Should contain translated strings (if translations loaded) + self.assertIsNotNone(message) + # In real scenario, would check for "¡Gracias!" or similar + + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_multilang_eu(self, mock_request): """Test confirmation message in Basque (eu_ES).""" - result = self._build_confirmation_result(lang="eu_ES", is_delivery=False) - self.assertIsNotNone(result["message"]) + mock_request.env = self.env.with_context(lang="eu_ES") - def test_build_confirmation_message_multilang_ca(self): + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + message = result["message"] + self.assertIsNotNone(message) + # In real scenario, would check for "Eskerrik asko!" or similar + + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_multilang_ca(self, mock_request): """Test confirmation message in Catalan (ca_ES).""" - result = self._build_confirmation_result(lang="ca_ES", is_delivery=False) - self.assertIsNotNone(result["message"]) + mock_request.env = self.env.with_context(lang="ca_ES") - def test_build_confirmation_message_multilang_gl(self): + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + message = result["message"] + self.assertIsNotNone(message) + # In real scenario, would check for "Gràcies!" or similar + + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_multilang_gl(self, mock_request): """Test confirmation message in Galician (gl_ES).""" - result = self._build_confirmation_result(lang="gl_ES", is_delivery=False) - self.assertIsNotNone(result["message"]) + mock_request.env = self.env.with_context(lang="gl_ES") - def test_build_confirmation_message_multilang_pt(self): + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + message = result["message"] + self.assertIsNotNone(message) + # In real scenario, would check for "Grazas!" or similar + + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_multilang_pt(self, mock_request): """Test confirmation message in Portuguese (pt_PT).""" - result = self._build_confirmation_result(lang="pt_PT", is_delivery=False) - self.assertIsNotNone(result["message"]) + mock_request.env = self.env.with_context(lang="pt_PT") - def test_build_confirmation_message_multilang_fr(self): + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + message = result["message"] + self.assertIsNotNone(message) + # In real scenario, would check for "Obrigado!" or similar + + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_multilang_fr(self, mock_request): """Test confirmation message in French (fr_FR).""" - result = self._build_confirmation_result(lang="fr_FR", is_delivery=False) - self.assertIsNotNone(result["message"]) + mock_request.env = self.env.with_context(lang="fr_FR") - def test_build_confirmation_message_multilang_it(self): + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + message = result["message"] + self.assertIsNotNone(message) + # In real scenario, would check for "Merci!" or similar + + @patch(REQUEST_PATCH_TARGET) + def test_build_confirmation_message_multilang_it(self, mock_request): """Test confirmation message in Italian (it_IT).""" - result = self._build_confirmation_result(lang="it_IT", is_delivery=False) - self.assertIsNotNone(result["message"]) + mock_request.env = self.env.with_context(lang="it_IT") + + result = self.controller._build_confirmation_message( + self.sale_order, self.group_order, is_delivery=False + ) + + message = result["message"] + self.assertIsNotNone(message) + # In real scenario, would check for "Grazie!" or similar class TestConfirmEskaera_Integration(TransactionCase): diff --git a/website_sale_aplicoop/tests/test_record_rules.py b/website_sale_aplicoop/tests/test_record_rules.py index 99604ee..8440f13 100644 --- a/website_sale_aplicoop/tests/test_record_rules.py +++ b/website_sale_aplicoop/tests/test_record_rules.py @@ -15,41 +15,16 @@ class TestGroupOrderRecordRules(TransactionCase): super().setUp() # Crear dos compañías - company_model = self.env["res.company"] - - # Compatibilidad con esquemas legacy: columna NOT NULL presente sin campo ORM. - self.env.cr.execute(""" - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'res_company' - AND column_name IN ('batch_summary_restriction_scope', 'batch_detailed_restriction_scope') - """) - existing_columns = {row[0] for row in self.env.cr.fetchall()} - if ( - "batch_summary_restriction_scope" in existing_columns - and "batch_summary_restriction_scope" not in company_model._fields - ): - self.env.cr.execute( - "ALTER TABLE res_company ALTER COLUMN batch_summary_restriction_scope SET DEFAULT 'processed'" - ) - if ( - "batch_detailed_restriction_scope" in existing_columns - and "batch_detailed_restriction_scope" not in company_model._fields - ): - self.env.cr.execute( - "ALTER TABLE res_company ALTER COLUMN batch_detailed_restriction_scope SET DEFAULT 'processed'" - ) - - def _company_vals(name): - vals = {"name": name} - if "batch_summary_restriction_scope" in company_model._fields: - vals["batch_summary_restriction_scope"] = "processed" - if "batch_detailed_restriction_scope" in company_model._fields: - vals["batch_detailed_restriction_scope"] = "processed" - return vals - - self.company1 = company_model.create(_company_vals("Company 1")) - self.company2 = company_model.create(_company_vals("Company 2")) + self.company1 = self.env["res.company"].create( + { + "name": "Company 1", + } + ) + self.company2 = self.env["res.company"].create( + { + "name": "Company 2", + } + ) # Crear usuarios para cada compañía self.user_company1 = self.env["res.users"].create( diff --git a/website_sale_aplicoop/tests/test_save_order_endpoints.py b/website_sale_aplicoop/tests/test_save_order_endpoints.py index fe5cfbc..7a559db 100644 --- a/website_sale_aplicoop/tests/test_save_order_endpoints.py +++ b/website_sale_aplicoop/tests/test_save_order_endpoints.py @@ -30,7 +30,6 @@ class TestSaveOrderEndpoints(TransactionCase): { "name": "Test Group", "is_company": True, - "is_group": True, "email": "group@test.com", } ) @@ -45,8 +44,6 @@ class TestSaveOrderEndpoints(TransactionCase): # Add member to group self.group.member_ids = [(4, self.member_partner.id)] - if "group_ids" in self.member_partner._fields: - self.member_partner.group_ids = [(4, self.group.id)] # Create test user self.user = self.env["res.users"].create( diff --git a/website_sale_aplicoop/views/load_from_history_templates.xml b/website_sale_aplicoop/views/load_from_history_templates.xml index e17a2dc..1f254df 100644 --- a/website_sale_aplicoop/views/load_from_history_templates.xml +++ b/website_sale_aplicoop/views/load_from_history_templates.xml @@ -16,21 +16,15 @@ var saleOrderName = ''; var pickupDay = ''; var pickupDate = ''; - var pickupSlotLabel = ''; var homeDelivery = ; var sameGroupOrder = ; - // Product availability warning - var hasUnavailableItems = ; - var warningMessage = ''; - console.log('load_from_history template: groupOrderId=', groupOrderId); console.log('load_from_history template: saleOrderName=', saleOrderName); console.log('load_from_history template: pickupDay=', pickupDay); console.log('load_from_history template: pickupDate=', pickupDate); console.log('load_from_history template: homeDelivery=', homeDelivery); console.log('load_from_history template: sameGroupOrder=', sameGroupOrder); - console.log('load_from_history template: hasUnavailableItems=', hasUnavailableItems); console.log('load_from_history template: itemsJson type=', typeof itemsJson); console.log('load_from_history template: itemsJson value=', itemsJson); @@ -47,21 +41,12 @@ if (sameGroupOrder === 'true') { sessionStorage['load_from_history_pickup_day_' + groupOrderId] = pickupDay; sessionStorage['load_from_history_pickup_date_' + groupOrderId] = pickupDate; - // Only store a human-readable label for pickup slot. - // Do NOT persist or expose internal slot IDs to sessionStorage. - sessionStorage['load_from_history_pickup_slot_label_' + groupOrderId] = pickupSlotLabel; sessionStorage['load_from_history_home_delivery_' + groupOrderId] = homeDelivery; console.log('Saved pickup fields (same group order)'); } else { console.log('Skipped saving pickup fields (different group order - will use current group order days)'); } - // Store warning about unavailable products if they exist - if (hasUnavailableItems === 'true') { - sessionStorage['load_from_history_warning_' + groupOrderId] = warningMessage; - console.log('Unavailable products detected:', warningMessage); - } - console.log('Saved to sessionStorage[load_from_history_' + groupOrderId + ']:', itemsJsonString); console.log('Saved order name to sessionStorage[load_from_history_order_name_' + groupOrderId + ']:', saleOrderName); diff --git a/website_sale_aplicoop/views/portal_templates.xml b/website_sale_aplicoop/views/portal_templates.xml index cc23fc8..10d1f77 100644 --- a/website_sale_aplicoop/views/portal_templates.xml +++ b/website_sale_aplicoop/views/portal_templates.xml @@ -89,14 +89,9 @@ - - - - - - , - - + + , + diff --git a/website_sale_aplicoop/views/product_category_views.xml b/website_sale_aplicoop/views/product_category_views.xml deleted file mode 100644 index 184f237..0000000 --- a/website_sale_aplicoop/views/product_category_views.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - product.category.list.sequence - product.category - - - - sequence, name - - - - - - - diff --git a/website_sale_aplicoop/views/sale_order_views.xml b/website_sale_aplicoop/views/sale_order_views.xml index 50b5616..5090249 100644 --- a/website_sale_aplicoop/views/sale_order_views.xml +++ b/website_sale_aplicoop/views/sale_order_views.xml @@ -13,7 +13,6 @@ - diff --git a/website_sale_aplicoop/views/website_sale_disable_cart.xml b/website_sale_aplicoop/views/website_sale_disable_cart.xml deleted file mode 100644 index 842ec3f..0000000 --- a/website_sale_aplicoop/views/website_sale_disable_cart.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml index c2e38eb..9a66ea8 100644 --- a/website_sale_aplicoop/views/website_templates.xml +++ b/website_sale_aplicoop/views/website_templates.xml @@ -123,26 +123,7 @@ - -
- Pickup slots - - -
- - - - - -   - - -
-
-
-
-
- +
Pickup @@ -210,31 +191,11 @@ ()
- -
- Store Pickup Slots - - -
- - - - - -   - - -
-
-
-
-
- +
Store Pickup Day - () - + ()
@@ -441,7 +402,7 @@