diff --git a/product_sale_price_from_pricelist/CHANGELOG.md b/product_sale_price_from_pricelist/CHANGELOG.md
index 0da015c..2c09d71 100644
--- a/product_sale_price_from_pricelist/CHANGELOG.md
+++ b/product_sale_price_from_pricelist/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [18.0.2.7.0] - 2026-02-27
+
+### Added
+
+- New action/button on sales orders to update non-invoiced orders from current product sale price (skips notes/downpayments, keeps discounts)
+- Server action binding for mass update from list view
+- Tests covering sales order update flow and invoiced order exclusion
+- Translations updated (es, eu) for the new UI strings
+
## [18.0.2.6.0] - 2026-02-14
### Fixed
diff --git a/product_sale_price_from_pricelist/README.rst b/product_sale_price_from_pricelist/README.rst
index 12320f3..f91561b 100644
--- a/product_sale_price_from_pricelist/README.rst
+++ b/product_sale_price_from_pricelist/README.rst
@@ -29,6 +29,7 @@ This module automatically calculates and updates product sale prices based on th
* **Batch Updates**: Update theoretical prices for multiple products
* **Product Flags**: Mark products for price updates and track status
* **Variant Architecture**: All business logic in product.product for proper pricelist handling
+* **Sales Orders Update**: Action/button to sync sale order lines (not invoiced) with current product sale price
**Table of contents**
@@ -95,6 +96,12 @@ Usage
#. System recalculates theoretical prices based on current settings
#. Review and apply changes
+**Update Sales Orders from List Price:**
+
+#. Go to Sales → Orders (form or list) and use **Update with List Price**
+#. Only orders not invoiced/canceled are processed
+#. The action copies current product sale price to normal lines (skipping notes and downpayments) and keeps discounts
+
**Price Calculation Flow:**
#. Purchase order is received
diff --git a/product_sale_price_from_pricelist/README_DEV.md b/product_sale_price_from_pricelist/README_DEV.md
index 6fd86ff..24b3b3e 100644
--- a/product_sale_price_from_pricelist/README_DEV.md
+++ b/product_sale_price_from_pricelist/README_DEV.md
@@ -15,6 +15,7 @@ Automatically calculate and update product sale prices based on the last purchas
- **UoM Conversion**: Handles different purchase and sale units of measure
- **Batch Updates**: Update theoretical prices for multiple products
- **Product Flags**: Mark products for price updates and track status
+- **Sales Orders Update**: Action/button to sync non-invoiced sale order lines with current product sale price
## Configuration
@@ -39,6 +40,7 @@ Automatically calculate and update product sale prices based on the last purchas
3. Sale price is calculated from the configured pricelist using the cost price
4. Taxes are automatically applied based on product tax settings
5. Go to **Products > Update Theoretical Prices** to batch update prices
+6. Go to **Sales > Orders** and use **Update with List Price** (form or list). Applies only to non-invoiced/non-canceled orders, skips notes and downpayments, preserves discounts.
## Technical Architecture
diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py
index 45f8f85..5c18831 100644
--- a/product_sale_price_from_pricelist/__manifest__.py
+++ b/product_sale_price_from_pricelist/__manifest__.py
@@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018
"name": "Product Sale Price from Pricelist",
- "version": "18.0.2.6.0",
+ "version": "18.0.2.7.0",
"category": "product",
"summary": "Set sale price from pricelist based on last purchase price",
"author": "Odoo Community Association (OCA), Criptomart",
@@ -20,5 +20,6 @@
"data": [
"views/actions.xml",
"views/product_view.xml",
+ "views/sale_order_view.xml",
],
}
diff --git a/product_sale_price_from_pricelist/i18n/es.po b/product_sale_price_from_pricelist/i18n/es.po
index 272316f..cf57d19 100644
--- a/product_sale_price_from_pricelist/i18n/es.po
+++ b/product_sale_price_from_pricelist/i18n/es.po
@@ -187,6 +187,32 @@ msgstr "Triple descuento"
msgid "Update Sales Price"
msgstr "Actualizar Precio de Venta"
+#. module: product_sale_price_from_pricelist
+#: model:ir.actions.server,name:product_sale_price_from_pricelist.action_sale_update_from_list_price
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Update with List Price"
+msgstr "Actualizar con precio de venta"
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Update skipped"
+msgstr "Actualización omitida"
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "No sales orders to update. Only non-invoiced and non-canceled orders are processed."
+msgstr "No hay pedidos de venta que actualizar. Solo se procesan los no facturados ni cancelados."
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Sales price update"
+msgstr "Actualización de precios de venta"
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Updated %(lines)s lines across %(orders)s sales orders."
+msgstr "Se actualizaron %(lines)s líneas en %(orders)s pedidos de venta."
+
#. module: product_sale_price_from_pricelist
#: model:ir.model.fields.selection,name:product_sale_price_from_pricelist.selection__product_template__last_purchase_price_compute_type__without_discounts
msgid "Without discounts"
diff --git a/product_sale_price_from_pricelist/i18n/eu.po b/product_sale_price_from_pricelist/i18n/eu.po
new file mode 100644
index 0000000..e709215
--- /dev/null
+++ b/product_sale_price_from_pricelist/i18n/eu.po
@@ -0,0 +1,43 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# \t* product_sale_price_from_pricelist
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 18.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2026-02-27 00:00+0000\n"
+"PO-Revision-Date: 2026-02-27 00:00+0000\n"
+"Last-Translator: \n"
+"Language-Team: \n"
+"Language: eu\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+
+#. module: product_sale_price_from_pricelist
+#: model:ir.actions.server,name:product_sale_price_from_pricelist.action_sale_update_from_list_price
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Update with List Price"
+msgstr "Eguneratu tarifa-prezioarekin"
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Update skipped"
+msgstr "Eguneratzea saltatu da"
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "No sales orders to update. Only non-invoiced and non-canceled orders are processed."
+msgstr "Ez dago eguneratzeko salmenta-agindurik. Soilik fakturatu gabe eta bertan behera utzi gabe daudenak prozesatzen dira."
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Sales price update"
+msgstr "Salmenta-prezioaren eguneraketa"
+
+#. module: product_sale_price_from_pricelist
+#: model_terms:ir.ui.view,arch_db:product_sale_price_from_pricelist.view_order_form_update_list_price
+msgid "Updated %(lines)s lines across %(orders)s sales orders."
+msgstr "Eguneratu dira %(orders)s salmenta-agindutako %(lines)s lerro."
diff --git a/product_sale_price_from_pricelist/models/__init__.py b/product_sale_price_from_pricelist/models/__init__.py
index f4e7395..97fb5c6 100644
--- a/product_sale_price_from_pricelist/models/__init__.py
+++ b/product_sale_price_from_pricelist/models/__init__.py
@@ -1,6 +1,7 @@
-from . import product_pricelist
-from . import product_pricelist_item
-from . import product_product
-from . import product_template
-from . import res_config
-from . import stock_move
+from . import product_pricelist # noqa: F401
+from . import product_pricelist_item # noqa: F401
+from . import product_product # noqa: F401
+from . import product_template # noqa: F401
+from . import res_config # noqa: F401
+from . import stock_move # noqa: F401
+from . import sale_order # noqa: F401
diff --git a/product_sale_price_from_pricelist/models/sale_order.py b/product_sale_price_from_pricelist/models/sale_order.py
new file mode 100644
index 0000000..2895f31
--- /dev/null
+++ b/product_sale_price_from_pricelist/models/sale_order.py
@@ -0,0 +1,72 @@
+import logging
+
+from odoo import models
+
+_logger = logging.getLogger(__name__)
+
+
+class SaleOrder(models.Model):
+ _inherit = "sale.order"
+
+ def action_update_lines_from_list_price(self):
+ """Copy current product list price into sale order lines.
+
+ Applies to orders that are not fully invoiced. Skips display and
+ downpayment lines, keeps discounts. Totals/taxes are recomputed by
+ field dependencies when the lines change.
+ """
+
+ eligible_orders = self.filtered(
+ lambda so: so.invoice_status not in ("invoiced", "up_to_date")
+ and so.state != "cancel"
+ )
+
+ if not eligible_orders:
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": self.env._("Update skipped"),
+ "message": self.env._(
+ "No sales orders to update. Only non-invoiced and non-canceled orders are processed."
+ ),
+ "type": "warning",
+ "sticky": False,
+ },
+ }
+
+ total_lines = 0
+ for order in eligible_orders:
+ lines = order.order_line.filtered(
+ lambda line: not line.display_type
+ and not line.is_downpayment
+ and line.product_id
+ )
+ for line in lines:
+ price_unit = line.product_id.uom_id._compute_price(
+ line.product_id.lst_price, line.product_uom
+ )
+ line.price_unit = price_unit
+
+ total_lines += len(lines)
+ _logger.info(
+ "[SALE PRICE UPDATE] Order %s (%s): updated %s lines",
+ order.name,
+ order.id,
+ len(lines),
+ )
+
+ return {
+ "type": "ir.actions.client",
+ "tag": "display_notification",
+ "params": {
+ "title": self.env._("Sales price update"),
+ "message": self.env._(
+ "Updated %(lines)s lines across %(orders)s sales orders.",
+ lines=total_lines,
+ orders=len(eligible_orders),
+ ),
+ "type": "success" if total_lines else "warning",
+ "sticky": False,
+ },
+ }
diff --git a/product_sale_price_from_pricelist/readme/USAGE.rst b/product_sale_price_from_pricelist/readme/USAGE.rst
index dd66ef2..0f48773 100644
--- a/product_sale_price_from_pricelist/readme/USAGE.rst
+++ b/product_sale_price_from_pricelist/readme/USAGE.rst
@@ -12,6 +12,12 @@
#. System recalculates theoretical prices based on current settings
#. Review and apply changes
+**Update Sales Orders from List Price:**
+
+#. Go to Sales → Orders (form or list) and click **Update with List Price**
+#. Only orders not invoiced or canceled are processed
+#. Copies current product sale price to normal lines (skips notes/sections and downpayments) and preserves discounts
+
**Price Calculation Flow:**
#. Purchase order is received
diff --git a/product_sale_price_from_pricelist/tests/test_sale_order_update.py b/product_sale_price_from_pricelist/tests/test_sale_order_update.py
new file mode 100644
index 0000000..0eefca0
--- /dev/null
+++ b/product_sale_price_from_pricelist/tests/test_sale_order_update.py
@@ -0,0 +1,95 @@
+# Copyright (C) 2026: Criptomart (https://criptomart.net)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+
+
+@tagged("post_install", "-at_install")
+class TestSaleOrderUpdate(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.partner = cls.env.ref("base.res_partner_1")
+
+ cls.product = cls.env["product.product"].create(
+ {
+ "name": "Test Product SO",
+ "list_price": 120.0,
+ "standard_price": 80.0,
+ }
+ )
+
+ cls.downpayment_product = cls.env["product.product"].create(
+ {
+ "name": "Downpayment Product",
+ "type": "service",
+ "list_price": 10.0,
+ }
+ )
+
+ def _create_sale_order(self, values=None):
+ vals = {
+ "partner_id": self.partner.id,
+ "order_line": [
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.product.id,
+ "product_uom": self.product.uom_id.id,
+ "product_uom_qty": 2.0,
+ "price_unit": 50.0,
+ "tax_id": [(6, 0, self.product.taxes_id.ids)],
+ },
+ ),
+ (0, 0, {"display_type": "line_note", "name": "Note"}),
+ (
+ 0,
+ 0,
+ {
+ "product_id": self.downpayment_product.id,
+ "product_uom": self.downpayment_product.uom_id.id,
+ "product_uom_qty": 1.0,
+ "price_unit": 10.0,
+ "is_downpayment": True,
+ },
+ ),
+ ],
+ }
+ if values:
+ vals.update(values)
+ return self.env["sale.order"].create(vals)
+
+ def test_update_lines_copies_list_price(self):
+ order = self._create_sale_order()
+
+ order.action_update_lines_from_list_price()
+
+ line = order.order_line.filtered(lambda line: line.product_id == self.product)
+ self.assertTrue(line)
+ self.assertEqual(line.price_unit, self.product.list_price)
+
+ # Downpayment and display lines remain unchanged
+ down_line = order.order_line.filtered("is_downpayment")
+ self.assertTrue(down_line)
+ self.assertEqual(down_line.price_unit, 10.0)
+
+ def test_invoiced_orders_are_skipped(self):
+ order = self._create_sale_order()
+ order.action_confirm()
+ invoice = order._create_invoices()
+ invoice.action_post()
+ order._compute_invoice_status()
+ self.assertEqual(order.invoice_status, "invoiced")
+
+ # Change product price to ensure potential change
+ order.order_line.filtered(
+ lambda line: line.product_id == self.product
+ ).price_unit = 5.0
+
+ order.action_update_lines_from_list_price()
+
+ # Price should remain untouched because order is invoiced
+ line = order.order_line.filtered(lambda line: line.product_id == self.product)
+ self.assertEqual(line.price_unit, 5.0)
diff --git a/product_sale_price_from_pricelist/views/sale_order_view.xml b/product_sale_price_from_pricelist/views/sale_order_view.xml
new file mode 100644
index 0000000..dd04f5a
--- /dev/null
+++ b/product_sale_price_from_pricelist/views/sale_order_view.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ Update with List Price
+
+
+ list,form
+ code
+ records.action_update_lines_from_list_price()
+
+
+
+
+ sale.order.form.update.list.price
+ sale.order
+
+
+
+
+
+
+
+
+
+
+ sale.order.tree.update.list.price
+ sale.order
+
+
+
+ state == 'cancel'
+
+
+
+
+
+
diff --git a/stock_picking_batch_custom/README.rst b/stock_picking_batch_custom/README.rst
new file mode 100644
index 0000000..9893a8f
--- /dev/null
+++ b/stock_picking_batch_custom/README.rst
@@ -0,0 +1,13 @@
+===============================
+Stock Picking Batch Custom
+===============================
+
+.. contents::
+ :local:
+
+.. include:: readme/DESCRIPTION.rst
+.. include:: readme/INSTALL.rst
+.. include:: readme/CONFIGURE.rst
+.. include:: readme/USAGE.rst
+.. include:: readme/CONTRIBUTORS.rst
+.. include:: readme/CREDITS.rst
diff --git a/stock_picking_batch_custom/__init__.py b/stock_picking_batch_custom/__init__.py
new file mode 100644
index 0000000..ce9807d
--- /dev/null
+++ b/stock_picking_batch_custom/__init__.py
@@ -0,0 +1 @@
+from . import models # noqa: F401
diff --git a/stock_picking_batch_custom/__manifest__.py b/stock_picking_batch_custom/__manifest__.py
new file mode 100644
index 0000000..4882701
--- /dev/null
+++ b/stock_picking_batch_custom/__manifest__.py
@@ -0,0 +1,18 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+{ # noqa: B018
+ "name": "Stock Picking Batch Custom",
+ "version": "18.0.1.0.0",
+ "category": "Warehouse",
+ "summary": "Extra columns for batch detailed operations",
+ "author": "Odoo Community Association (OCA), Criptomart",
+ "maintainers": ["Criptomart"],
+ "website": "https://github.com/Criptomart",
+ "license": "AGPL-3",
+ "depends": [
+ "stock_picking_batch",
+ ],
+ "data": [
+ "views/stock_move_line_views.xml",
+ ],
+}
diff --git a/stock_picking_batch_custom/models/__init__.py b/stock_picking_batch_custom/models/__init__.py
new file mode 100644
index 0000000..5a228fe
--- /dev/null
+++ b/stock_picking_batch_custom/models/__init__.py
@@ -0,0 +1 @@
+from . import stock_move_line # noqa: F401
diff --git a/stock_picking_batch_custom/models/stock_move_line.py b/stock_picking_batch_custom/models/stock_move_line.py
new file mode 100644
index 0000000..cb4ad48
--- /dev/null
+++ b/stock_picking_batch_custom/models/stock_move_line.py
@@ -0,0 +1,17 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import fields
+from odoo import models
+
+
+class StockMoveLine(models.Model):
+ _inherit = "stock.move.line"
+
+ product_categ_id = fields.Many2one(
+ comodel_name="product.category",
+ string="Product Category",
+ related="product_id.categ_id",
+ store=True,
+ readonly=True,
+ )
diff --git a/stock_picking_batch_custom/readme/CONFIGURE.rst b/stock_picking_batch_custom/readme/CONFIGURE.rst
new file mode 100644
index 0000000..2213143
--- /dev/null
+++ b/stock_picking_batch_custom/readme/CONFIGURE.rst
@@ -0,0 +1,8 @@
+Configuración
+=============
+
+No requiere configuración adicional. Para usar las columnas:
+
+- Abrir un **Lote de picking**.
+- Ir a la pestaña **Detailed Operations**.
+- Abrir el **selector de columnas** y activar *Partner* y *Product Category* según necesidad.
diff --git a/stock_picking_batch_custom/readme/CONTRIBUTORS.rst b/stock_picking_batch_custom/readme/CONTRIBUTORS.rst
new file mode 100644
index 0000000..46076fe
--- /dev/null
+++ b/stock_picking_batch_custom/readme/CONTRIBUTORS.rst
@@ -0,0 +1,4 @@
+Contribuidores
+==============
+
+* Criptomart
diff --git a/stock_picking_batch_custom/readme/CREDITS.rst b/stock_picking_batch_custom/readme/CREDITS.rst
new file mode 100644
index 0000000..0fee3c5
--- /dev/null
+++ b/stock_picking_batch_custom/readme/CREDITS.rst
@@ -0,0 +1,12 @@
+Créditos
+========
+
+Autor
+-----
+
+* Criptomart
+
+Financiador
+-----------
+
+* Elika Bilbo
diff --git a/stock_picking_batch_custom/readme/DESCRIPTION.rst b/stock_picking_batch_custom/readme/DESCRIPTION.rst
new file mode 100644
index 0000000..d8fc673
--- /dev/null
+++ b/stock_picking_batch_custom/readme/DESCRIPTION.rst
@@ -0,0 +1,11 @@
+Este módulo añade dos columnas opcionales en las operaciones detalladas de los
+lotes de picking:
+
+- ``picking_partner_id`` (Partner del albarán) para que el personal de almacén
+ pueda identificar rápidamente el cliente/proveedor asociado.
+- ``product_categ_id`` (Categoría de producto) para permitir ordenación y
+ agrupación por categoría.
+
+Ambas columnas se añaden como ``optional="hide"`` en la vista de líneas del
+lote, de modo que el usuario puede activarlas desde el selector de columnas sin
+cargar la vista por defecto.
diff --git a/stock_picking_batch_custom/readme/INSTALL.rst b/stock_picking_batch_custom/readme/INSTALL.rst
new file mode 100644
index 0000000..b259091
--- /dev/null
+++ b/stock_picking_batch_custom/readme/INSTALL.rst
@@ -0,0 +1,8 @@
+Instalación
+===========
+
+Actualizar o instalar el módulo:
+
+::
+
+ docker-compose run --rm odoo odoo -d odoo --stop-after-init -u stock_picking_batch_custom
diff --git a/stock_picking_batch_custom/readme/USAGE.rst b/stock_picking_batch_custom/readme/USAGE.rst
new file mode 100644
index 0000000..d652a4e
--- /dev/null
+++ b/stock_picking_batch_custom/readme/USAGE.rst
@@ -0,0 +1,10 @@
+Uso
+===
+
+1. Accede a **Inventory > Operations > Batch Transfers** y abre un lote.
+2. Pestaña **Detailed Operations**: usa el selector de columnas para activar:
+
+ - **Partner** (``picking_partner_id``) para ver el cliente/proveedor.
+ - **Product Category** (``product_categ_id``) para ordenar/agrupación por categoría.
+
+3. Ordena o agrupa por la columna de categoría según convenga.
diff --git a/stock_picking_batch_custom/static/description/icon.png b/stock_picking_batch_custom/static/description/icon.png
new file mode 100644
index 0000000..d4a79d1
Binary files /dev/null and b/stock_picking_batch_custom/static/description/icon.png differ
diff --git a/stock_picking_batch_custom/views/stock_move_line_views.xml b/stock_picking_batch_custom/views/stock_move_line_views.xml
new file mode 100644
index 0000000..a000a97
--- /dev/null
+++ b/stock_picking_batch_custom/views/stock_move_line_views.xml
@@ -0,0 +1,16 @@
+
+
+
+ stock.move.line.list.batch.custom
+ stock.move.line
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py
index 9158b79..e3fac6b 100644
--- a/website_sale_aplicoop/controllers/website_sale.py
+++ b/website_sale_aplicoop/controllers/website_sale.py
@@ -6,6 +6,7 @@ import logging
from datetime import datetime
from datetime import timedelta
+from odoo import fields
from odoo import http
from odoo.http import request
@@ -302,7 +303,6 @@ class AplicoopWebsiteSale(WebsiteSale):
# Agregar a los hijos de su padre
category_map[parent_id]["children"].append(cat_info)
- # Ordenar raíces y sus hijos por nombre
def sort_hierarchy(items):
items.sort(key=lambda x: x["name"])
for item in items:
@@ -325,87 +325,49 @@ class AplicoopWebsiteSale(WebsiteSale):
Returns:
product.pricelist record or False if none found
"""
+ env = request.env
+ website = request.website
pricelist = None
- # Try to get configured Aplicoop pricelist first
+ # 1) Configured pricelist from settings
try:
- aplicoop_pricelist_id = (
- request.env["ir.config_parameter"]
+ param_value = (
+ env["ir.config_parameter"]
.sudo()
.get_param("website_sale_aplicoop.pricelist_id")
)
- if aplicoop_pricelist_id:
+ if param_value:
pricelist = (
- request.env["product.pricelist"]
- .sudo()
- .browse(int(aplicoop_pricelist_id))
+ env["product.pricelist"].browse(int(param_value)).exists() or None
)
- if pricelist.exists():
- _logger.info(
- "_resolve_pricelist: Using configured Aplicoop pricelist: %s (id=%s)",
- pricelist.name,
- pricelist.id,
- )
- return pricelist
- else:
- _logger.warning(
- "_resolve_pricelist: Configured Aplicoop pricelist (id=%s) not found",
- aplicoop_pricelist_id,
- )
- except Exception as err:
- _logger.warning(
- "_resolve_pricelist: Error getting Aplicoop pricelist: %s", str(err)
- )
+ except Exception as e:
+ _logger.warning("_resolve_pricelist: error reading config param: %s", e)
- # Fallback to website pricelist
- try:
- pricelist = request.website._get_current_pricelist()
- if pricelist:
- _logger.info(
- "_resolve_pricelist: Using website pricelist: %s (id=%s)",
- pricelist.name,
- pricelist.id,
+ # 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
)
- return pricelist
- except Exception as err:
- _logger.warning(
- "_resolve_pricelist: Error getting website pricelist: %s", str(err)
- )
- # Final fallback to first active pricelist
- pricelist = (
- request.env["product.pricelist"]
- .sudo()
- .search([("active", "=", True)], limit=1)
- )
- if pricelist:
- _logger.info(
- "_resolve_pricelist: Using first active pricelist: %s (id=%s)",
- pricelist.name,
- pricelist.id,
- )
- return pricelist
+ # 3) First active pricelist as fallback
+ if not pricelist:
+ pricelist = env["product.pricelist"].sudo().search([], limit=1)
- _logger.error(
- "_resolve_pricelist: ERROR - No pricelist found! Pricing may fail."
- )
- return False
+ return pricelist
def _prepare_product_display_info(self, product, product_price_info):
- """Prepare all display information for a product in a QWeb-safe way.
-
- This function pre-processes all values that might be None or require
- conditional logic, so the template can use simple variable references
- without complex expressions that confuse QWeb's parser.
+ """Build display info for a product using precomputed prices.
Args:
- product: product.template record
- product_price_info: dict with 'price', 'list_price', etc.
+ product (product.product): Product variant.
+ product_price_info: dict with price data keyed by product.id.
Returns:
- dict with all pre-processed display values ready for template
+ dict with display_price, safe_uom_category, quantity_step
"""
- # Safety: Get price, ensure it's a float
price_data = product_price_info.get(product.id, {})
price = (
price_data.get("price", product.list_price)
@@ -414,19 +376,14 @@ class AplicoopWebsiteSale(WebsiteSale):
)
price_safe = float(price) if price else 0.0
- # Safety: Get UoM category name (use sudo for read-only display to avoid ACL issues)
uom_category_name = ""
- quantity_step = 1 # Default step for integer quantities (Units)
+ quantity_step = 1
if product.uom_id:
uom = product.uom_id.sudo()
if uom.category_id:
uom_category_name = uom.category_id.sudo().name or ""
-
- # Use XML IDs to detect fractional UoM categories (multilingual robust)
- # This works regardless of translation/language
try:
- # Get external ID for the UoM category
ir_model_data = request.env["ir.model.data"].sudo()
external_id = ir_model_data.search(
[
@@ -437,18 +394,16 @@ class AplicoopWebsiteSale(WebsiteSale):
)
if external_id:
- # Standard Odoo UoM categories requiring fractional step
fractional_categories = [
- "uom.product_uom_categ_kgm", # Weight (kg, g, ton, etc.)
- "uom.product_uom_categ_vol", # Volume (L, m³, etc.)
- "uom.uom_categ_length", # Length/Distance (m, km, etc.)
- "uom.uom_categ_surface", # Surface (m², ha, etc.)
+ "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
except Exception as e:
- # Fallback to integer step on error
_logger.warning(
"_prepare_product_display_info: Error detecting UoM category XML ID for product %s: %s",
product.id,
@@ -461,6 +416,70 @@ class AplicoopWebsiteSale(WebsiteSale):
"quantity_step": quantity_step,
}
+ def _get_pricing_info(self, product, pricelist, quantity=1.0, partner=None):
+ """Compute pricing with taxes like website_sale but using a given pricelist.
+
+ Returns a dict with:
+ - price_unit: raw pricelist price (before taxes), suitable for sale.order.line
+ - price: display price (tax included/excluded per website setting)
+ - list_price: display list price (pre-discount) with same tax display
+ - has_discounted_price: bool
+ """
+
+ try:
+ env = request.env
+ website = request.website
+ except RuntimeError:
+ env = product.env
+ website = env["website"].get_current_website()
+
+ partner = partner or env.user.partner_id
+ currency = pricelist.currency_id
+ company = website.company_id or product.company_id or env.company
+
+ price, rule_id = pricelist._get_product_price_rule(
+ product=product,
+ quantity=quantity,
+ target_currency=currency,
+ )
+
+ price_before_discount = price
+ pricelist_item = env["product.pricelist.item"].browse(rule_id)
+ if pricelist_item and pricelist_item._show_discount_on_shop():
+ price_before_discount = pricelist_item._compute_price_before_discount(
+ product=product,
+ quantity=quantity or 1.0,
+ date=fields.Date.context_today(pricelist),
+ uom=product.uom_id,
+ currency=currency,
+ )
+
+ has_discounted_price = price_before_discount > price
+
+ fiscal_position = website.fiscal_position_id.sudo()
+ product_taxes = product.sudo().taxes_id._filter_taxes_by_company(company)
+ taxes = (
+ fiscal_position.map_tax(product_taxes) if product_taxes else product_taxes
+ )
+ tax_display = "total_included"
+
+ def compute_display(amount):
+ if not taxes:
+ return amount
+ return taxes.compute_all(amount, currency, 1, product, partner)[tax_display]
+
+ display_price = compute_display(price)
+ display_list_price = compute_display(price_before_discount)
+
+ return {
+ "price_unit": price,
+ "price": display_price,
+ "list_price": display_list_price,
+ "has_discounted_price": has_discounted_price,
+ "discount": display_list_price - display_price,
+ "tax_included": tax_display == "total_included",
+ }
+
def _compute_price_info(self, products, pricelist):
"""Compute price info dict for a list of products using the given pricelist.
@@ -473,23 +492,13 @@ class AplicoopWebsiteSale(WebsiteSale):
)
if product_variant and pricelist:
try:
- price_info = product_variant._get_price(
- qty=1.0,
- pricelist=pricelist,
- fposition=request.website.fiscal_position_id,
+ pricing = self._get_pricing_info(
+ product_variant,
+ pricelist,
+ quantity=1.0,
+ partner=request.env.user.partner_id,
)
- price = price_info.get("value", 0.0)
- original_price = price_info.get("original_value", 0.0)
- discount = price_info.get("discount", 0.0)
- has_discount = discount > 0
-
- product_price_info[product.id] = {
- "price": price,
- "list_price": original_price,
- "has_discounted_price": has_discount,
- "discount": discount,
- "tax_included": price_info.get("tax_included", True),
- }
+ 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.",
@@ -498,19 +507,23 @@ class AplicoopWebsiteSale(WebsiteSale):
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": False,
+ "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": False,
+ "tax_included": request.website.show_line_subtotals_tax_selection
+ != "tax_excluded",
}
return product_price_info
@@ -814,7 +827,7 @@ class AplicoopWebsiteSale(WebsiteSale):
return order_id, group_order, current_user, items, is_delivery
- def _process_cart_items(self, items, group_order):
+ def _process_cart_items(self, items, group_order, pricelist=None):
"""Process cart items and build sale.order line data.
Args:
@@ -828,12 +841,13 @@ class AplicoopWebsiteSale(WebsiteSale):
ValueError: if no valid items after processing
"""
sale_order_lines = []
+ pricelist = pricelist or self._resolve_pricelist()
+ partner = request.env.user.partner_id
for item in items:
try:
product_id = int(item.get("product_id"))
quantity = float(item.get("quantity", 1))
- price = float(item.get("product_price", 0))
product = request.env["product.product"].sudo().browse(product_id)
if not product.exists():
@@ -846,10 +860,17 @@ class AplicoopWebsiteSale(WebsiteSale):
product_in_lang = product.with_context(lang=request.env.lang)
product_name = product_in_lang.name
+ pricing = self._get_pricing_info(
+ product,
+ pricelist,
+ quantity=quantity,
+ partner=partner,
+ )
+
line_data = {
"product_id": product_id,
"product_uom_qty": quantity,
- "price_unit": price or product.list_price,
+ "price_unit": pricing.get("price_unit", product.list_price),
"name": product_name, # Force the translated product name
}
_logger.info("_process_cart_items: Adding line: %s", line_data)
@@ -1018,19 +1039,7 @@ class AplicoopWebsiteSale(WebsiteSale):
return sale_order
def _build_confirmation_message(self, sale_order, group_order, is_delivery):
- """Build localized confirmation message for confirm_eskaera.
-
- Translates message and pickup/delivery info according to user's language.
- Handles day names and date formatting.
-
- Args:
- sale_order: sale.order record just created
- group_order: group.order record
- is_delivery: boolean indicating if home delivery
-
- Returns:
- dict with message, pickup_day, pickup_date, pickup_day_index
- """
+ """Build localized confirmation message for confirm_eskaera."""
# Get pickup day index, localized name and date string using helper
pickup_day_name, pickup_date_str, pickup_day_index = self._format_pickup_info(
group_order, is_delivery
@@ -1220,86 +1229,29 @@ class AplicoopWebsiteSale(WebsiteSale):
group_order,
current_user,
sale_order_lines,
- merge_action,
- existing_draft_id,
existing_drafts,
order_id,
):
- """Handle merge/replace logic for drafts and return (sale_order, merge_success).
+ """Replace existing draft (if any) with new lines, else create it."""
- existing_drafts: recordset of existing draft orders (may be empty)
- """
- # Merge
- if merge_action == "merge" and existing_draft_id:
- existing_draft = (
- request.env["sale.order"].sudo().browse(int(existing_draft_id))
- )
- if existing_draft.exists():
- for new_line_data in sale_order_lines:
- product_id = new_line_data[2]["product_id"]
- new_quantity = new_line_data[2]["product_uom_qty"]
- new_price = new_line_data[2]["price_unit"]
-
- # Capture product_id as default arg to avoid late-binding in lambda (fix B023)
- existing_line = existing_draft.order_line.filtered(
- lambda line, pid=product_id: line.product_id.id == pid
- )
- if existing_line:
- # Use sudo() to avoid permission issues with portal users
- existing_line.sudo().write(
- {
- "product_uom_qty": existing_line.product_uom_qty
- + new_quantity
- }
- )
- _logger.info(
- "Merged item: product_id=%d, new total quantity=%.2f",
- product_id,
- existing_line.product_uom_qty,
- )
- else:
- # Use sudo() to avoid permission issues with portal users
- existing_draft.order_line.sudo().create(
- {
- "order_id": existing_draft.id,
- "product_id": product_id,
- "product_uom_qty": new_quantity,
- "price_unit": new_price,
- }
- )
- _logger.info(
- "Added new item to draft: product_id=%d, quantity=%.2f",
- product_id,
- new_quantity,
- )
-
- return existing_draft, True
-
- # Replace
- if merge_action == "replace" and existing_draft_id and existing_drafts:
- existing_drafts.unlink()
+ if existing_drafts:
+ draft = existing_drafts[0].sudo()
_logger.info(
- "Deleted existing draft(s) for replace: %s",
- existing_drafts.mapped("id"),
+ "Replacing existing draft order %s for partner %s",
+ draft.id,
+ current_user.partner_id.id,
)
- order_vals = {
- "partner_id": current_user.partner_id.id,
- "order_line": sale_order_lines,
- "state": "draft",
- "group_order_id": order_id,
- "pickup_day": group_order.pickup_day,
- "pickup_date": group_order.pickup_date,
- "home_delivery": group_order.home_delivery,
- }
- # Get salesperson for order creation (portal users need this)
- salesperson = self._get_salesperson_for_order(current_user.partner_id)
- if salesperson:
- order_vals["user_id"] = salesperson.id
+ draft.write(
+ {
+ "order_line": [(5, 0, 0)] + sale_order_lines,
+ "group_order_id": order_id,
+ "pickup_day": group_order.pickup_day,
+ "pickup_date": group_order.pickup_date,
+ "home_delivery": group_order.home_delivery,
+ }
+ )
+ return draft
- sale_order = request.env["sale.order"].sudo().create(order_vals)
- return sale_order, False
-
- # Default: create new draft
order_vals = {
"partner_id": current_user.partner_id.id,
"order_line": sale_order_lines,
@@ -1315,7 +1267,7 @@ class AplicoopWebsiteSale(WebsiteSale):
order_vals["user_id"] = salesperson.id
sale_order = request.env["sale.order"].sudo().create(order_vals)
- return sale_order, False
+ return sale_order
def _decode_json_body(self):
"""Safely decode JSON body from request. Returns dict or raises ValueError."""
@@ -2045,7 +1997,9 @@ class AplicoopWebsiteSale(WebsiteSale):
# Build sale.order lines and create draft using helpers
try:
- sale_order_lines = self._process_cart_items(items, group_order)
+ sale_order_lines = self._process_cart_items(
+ items, group_order, pricelist=self._resolve_pricelist()
+ )
except ValueError as e:
return request.make_response(
json.dumps({"error": str(e)}),
@@ -2171,13 +2125,21 @@ class AplicoopWebsiteSale(WebsiteSale):
# Extract items from the draft order
items = []
+ pricelist = self._resolve_pricelist()
+ partner = current_user.partner_id
for line in draft_order.order_line:
+ pricing = self._get_pricing_info(
+ line.product_id,
+ pricelist,
+ quantity=line.product_uom_qty,
+ partner=partner,
+ )
items.append(
{
"product_id": line.product_id.id,
"product_name": line.product_id.name,
"quantity": line.product_uom_qty,
- "product_price": line.price_unit,
+ "product_price": pricing.get("price", line.price_unit),
}
)
@@ -2306,8 +2268,6 @@ class AplicoopWebsiteSale(WebsiteSale):
# Get cart items
items = data.get("items", [])
- merge_action = data.get("merge_action") # 'merge' or 'replace'
- existing_draft_id = data.get("existing_draft_id") # ID if replacing
if not items:
_logger.warning(
@@ -2334,35 +2294,6 @@ class AplicoopWebsiteSale(WebsiteSale):
)
)
- # If draft exists and no action specified, return the existing draft info
- if existing_drafts and not merge_action:
- existing_draft = existing_drafts[0] # Get first draft
- existing_items = [
- {
- "product_id": line.product_id.id,
- "product_name": line.product_id.name,
- "quantity": line.product_uom_qty,
- "product_price": line.price_unit,
- }
- for line in existing_draft.order_line
- ]
-
- return request.make_response(
- json.dumps(
- {
- "success": False,
- "existing_draft": True,
- "existing_draft_id": existing_draft.id,
- "existing_items": existing_items,
- "current_items": items,
- "message": request.env._(
- "A draft already exists for this week."
- ),
- }
- ),
- [("Content-Type", "application/json")],
- )
-
_logger.info(
"Creating draft sale.order with %d items for partner %d",
len(items),
@@ -2371,7 +2302,9 @@ class AplicoopWebsiteSale(WebsiteSale):
# Create sales.order lines from items using shared helper
try:
- sale_order_lines = self._process_cart_items(items, group_order)
+ sale_order_lines = self._process_cart_items(
+ items, group_order, pricelist=self._resolve_pricelist()
+ )
except ValueError as e:
return request.make_response(
json.dumps({"error": str(e)}),
@@ -2380,12 +2313,10 @@ class AplicoopWebsiteSale(WebsiteSale):
)
# Delegate merge/replace/create logic to helper
- sale_order, merge_success = self._merge_or_replace_draft(
+ sale_order = self._merge_or_replace_draft(
group_order,
current_user,
sale_order_lines,
- merge_action,
- existing_draft_id,
existing_drafts,
order_id,
)
@@ -2404,13 +2335,8 @@ class AplicoopWebsiteSale(WebsiteSale):
json.dumps(
{
"success": True,
- "message": (
- request.env._("Merged with existing draft")
- if merge_success
- else request.env._("Order saved as draft")
- ),
+ "message": request.env._("Order saved as draft"),
"sale_order_id": sale_order.id,
- "merged": merge_success,
}
),
[("Content-Type", "application/json")],
@@ -2481,7 +2407,9 @@ class AplicoopWebsiteSale(WebsiteSale):
# Process cart items using helper
try:
- sale_order_lines = self._process_cart_items(items, group_order)
+ sale_order_lines = self._process_cart_items(
+ items, group_order, pricelist=self._resolve_pricelist()
+ )
except ValueError as e:
_logger.warning("confirm_eskaera: Cart processing error: %s", str(e))
return request.make_response(
diff --git a/website_sale_aplicoop/models/group_order.py b/website_sale_aplicoop/models/group_order.py
index 8090f27..a4da72c 100644
--- a/website_sale_aplicoop/models/group_order.py
+++ b/website_sale_aplicoop/models/group_order.py
@@ -680,4 +680,44 @@ class GroupOrder(models.Model):
order._compute_cutoff_date()
order._compute_pickup_date()
order._compute_delivery_date()
+ order._confirm_linked_sale_orders()
_logger.info("Cron: Date update completed")
+
+ def _confirm_linked_sale_orders(self):
+ """Confirm draft/sent sale orders linked to this group order.
+
+ This is triggered by the daily cron so that weekly orders generated
+ from the website are confirmed automatically once dates are refreshed.
+ """
+ self.ensure_one()
+ SaleOrder = self.env["sale.order"].sudo()
+ sale_orders = SaleOrder.search(
+ [
+ ("group_order_id", "=", self.id),
+ ("state", "in", ["draft", "sent"]),
+ ]
+ )
+
+ if not sale_orders:
+ _logger.info(
+ "Cron: No sale orders to confirm for group order %s (%s)",
+ self.id,
+ self.name,
+ )
+ return
+
+ _logger.info(
+ "Cron: Confirming %d sale orders for group order %s (%s)",
+ len(sale_orders),
+ self.id,
+ self.name,
+ )
+
+ try:
+ sale_orders.action_confirm()
+ except Exception:
+ _logger.exception(
+ "Cron: Error confirming sale orders for group order %s (%s)",
+ self.id,
+ self.name,
+ )
diff --git a/website_sale_aplicoop/static/src/js/home_delivery.js b/website_sale_aplicoop/static/src/js/home_delivery.js
index 68de98b..d7d3d5a 100644
--- a/website_sale_aplicoop/static/src/js/home_delivery.js
+++ b/website_sale_aplicoop/static/src/js/home_delivery.js
@@ -213,6 +213,14 @@
var cartKey = "eskaera_" + this.orderId + "_cart";
localStorage.setItem(cartKey, JSON.stringify(cart));
+ // Mantener sincronizado el carrito en memoria (sidebar de tienda)
+ if (window.groupOrderShop && window.groupOrderShop.cart !== undefined) {
+ window.groupOrderShop.cart = cart;
+ if (typeof window.groupOrderShop._updateCartDisplay === "function") {
+ window.groupOrderShop._updateCartDisplay();
+ }
+ }
+
// Re-render checkout summary without reloading
setTimeout(function () {
// Use the global function from checkout_labels.js
diff --git a/website_sale_aplicoop/static/src/js/website_sale.js b/website_sale_aplicoop/static/src/js/website_sale.js
index 65010d9..0e01f88 100644
--- a/website_sale_aplicoop/static/src/js/website_sale.js
+++ b/website_sale_aplicoop/static/src/js/website_sale.js
@@ -449,7 +449,7 @@
var tooltipMap = {
"save-cart-btn": "save_cart",
"reload-cart-btn": "reload_cart",
- "confirm-order-btn": "confirm_order",
+ "confirm-order-btn": "save_cart",
"remove-from-cart": "remove_item",
};
@@ -634,9 +634,9 @@
if (confirmBtn) {
confirmBtn.addEventListener("click", function (e) {
- console.log("[CLICK] confirm-order-btn clicked");
+ console.log("[CLICK] confirm-order-btn clicked (save draft)");
e.preventDefault();
- self._confirmOrder();
+ self._saveOrderDraft();
});
}
@@ -1313,6 +1313,7 @@
var orderData = {
order_id: this.orderId,
items: items,
+ merge_action: "replace",
};
var xhr = new XMLHttpRequest();
@@ -1332,9 +1333,6 @@
data.sale_order_id +
")";
self._showNotification("✓ " + successMsg, "success", 5000);
- } else if (data.existing_draft) {
- // A draft already exists - show modal with merge/replace options
- self._showDraftConflictModal(data);
} else {
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
@@ -1499,10 +1497,14 @@
console.log("[_saveOrderDraft] Starting - this.orderId:", this.orderId);
var self = this;
+ var labels = this._getLabels();
+ var cartKey = "eskaera_" + this.orderId + "_cart";
+ var storedCart = localStorage.getItem(cartKey);
+ var cart = storedCart ? JSON.parse(storedCart) : this.cart;
var items = [];
- Object.keys(this.cart).forEach(function (productId) {
- var item = self.cart[productId];
+ Object.keys(cart).forEach(function (productId) {
+ var item = cart[productId];
items.push({
product_id: productId,
product_name: item.name,
@@ -1527,14 +1529,11 @@
console.log("Response:", data);
if (data.success) {
- self._showNotification(
- "✓ Order saved as draft successfully",
- "success",
- 5000
- );
- } else if (data.existing_draft) {
- // A draft already exists - show modal with merge/replace options
- self._showDraftConflictModal(data);
+ var successMsg =
+ labels.draft_saved_success ||
+ labels.draft_saved ||
+ "Order saved as draft successfully";
+ self._showNotification("\u2713 " + successMsg, "success", 5000);
} else {
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),
@@ -1572,306 +1571,6 @@
xhr.send(JSON.stringify(orderData));
},
- _showDraftConflictModal: function (data) {
- /**
- * Show modal with merge/replace options for existing draft.
- * Uses labels from window.groupOrderShop.labels or falls back to defaults.
- */
- var self = this;
-
- // Get labels - they should already be loaded by page init
- var labels =
- window.groupOrderShop && window.groupOrderShop.labels
- ? window.groupOrderShop.labels
- : self._getDefaultLabels();
-
- console.log(
- "[_showDraftConflictModal] Using labels:",
- Object.keys(labels).length,
- "keys available"
- );
-
- var existing_items = data.existing_items || [];
- var current_items = data.current_items || [];
- var existing_draft_id = data.existing_draft_id;
-
- // Create modal HTML with inline styles (no Bootstrap needed)
- var modalHTML = `
-
-
-
-
-
${
- labels.draft_already_exists || "Draft Already Exists"
- }
-
-
-
-
-
-
${
- labels.draft_exists_message || "A draft already exists"
- }
-
${
- labels.draft_two_options || "You have two options:"
- }
-
-
-
-
- ${
- labels.draft_option1_title || "Option 1"
- }
-
-
-
${labels.draft_option1_desc || "Merge with existing"}
-
- - ${existing_items.length} ${
- labels.draft_items_count || "items"
- } - ${labels.draft_existing_items || "Existing"}
- - ${current_items.length} ${
- labels.draft_items_count || "items"
- } - ${labels.draft_current_items || "Current"}
-
-
- ${labels.draft_merge_note || "Products will be merged"}
-
-
-
-
-
-
-
- ${
- labels.draft_option2_title || "Option 2"
- }
-
-
-
${labels.draft_option2_desc || "Replace with current"}
-
- ${
- labels.draft_replace_warning ||
- "Old draft will be deleted"
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
-
- // Remove existing modal if any
- var existingModal = document.getElementById("draftConflictModal");
- if (existingModal) {
- existingModal.remove();
- }
-
- // Add modal to body
- document.body.insertAdjacentHTML("beforeend", modalHTML);
- var modalElement = document.getElementById("draftConflictModal");
-
- // Handle close buttons
- document.querySelectorAll(".draft-modal-close").forEach(function (btn) {
- btn.addEventListener("click", function () {
- modalElement.remove();
- });
- });
-
- // Handle merge button
- document.getElementById("mergeBtn").addEventListener("click", function () {
- var existingId = this.getAttribute("data-existing-id");
- modalElement.remove();
- self._executeSaveDraftWithAction("merge", existingId);
- });
-
- // Handle replace button
- document.getElementById("replaceBtn").addEventListener("click", function () {
- var existingId = this.getAttribute("data-existing-id");
- modalElement.remove();
- self._executeSaveDraftWithAction("replace", existingId);
- });
-
- // Close modal when clicking outside
- modalElement.addEventListener("click", function (e) {
- if (e.target === modalElement) {
- modalElement.remove();
- }
- });
- },
-
- _executeSaveDraftWithAction: function (action, existingDraftId) {
- /**
- * Execute save draft with merge or replace action.
- */
- var self = this;
- var items = [];
-
- Object.keys(this.cart).forEach(function (productId) {
- var item = self.cart[productId];
- items.push({
- product_id: productId,
- product_name: item.name,
- quantity: item.qty,
- product_price: item.price,
- });
- });
-
- var orderData = {
- order_id: this.orderId,
- items: items,
- merge_action: action,
- existing_draft_id: existingDraftId,
- };
-
- var xhr = new XMLHttpRequest();
- xhr.open("POST", "/eskaera/save-order", true);
- xhr.setRequestHeader("Content-Type", "application/json");
-
- xhr.onload = function () {
- if (xhr.status === 200) {
- try {
- var data = JSON.parse(xhr.responseText);
- console.log("Response:", data);
- if (data.success) {
- // Use server-provided labels instead of hardcoding
- var labels = self._getLabels();
-
- // Use the translated messages from server
- var msg = data.merged
- ? "✓ " +
- (labels.draft_merged_success || "Draft merged successfully")
- : "✓ " +
- (labels.draft_replaced_success || "Draft replaced successfully");
-
- self._showNotification(msg, "success", 5000);
- } else {
- var labels = self._getLabels();
- self._showNotification(
- "Error: " + (data.error || labels.error_unknown || "Unknown error"),
- "danger"
- );
- }
- } catch (e) {
- console.error("Error parsing response:", e);
- self._showNotification("Error processing response", "danger");
- }
- } else {
- try {
- var errorData = JSON.parse(xhr.responseText);
- self._showNotification(
- "Error " + xhr.status + ": " + (errorData.error || "Request error"),
- "danger"
- );
- } catch (e) {
- self._showNotification(
- "Error saving order (HTTP " + xhr.status + ")",
- "danger"
- );
- }
- }
- };
-
- xhr.onerror = function () {
- self._showNotification("Connection error", "danger");
- };
-
- xhr.send(JSON.stringify(orderData));
- },
-
_confirmOrder: function () {
console.log("=== _confirmOrder started ===");
console.log("orderId:", this.orderId);
diff --git a/website_sale_aplicoop/tests/test_pricing_with_pricelist.py b/website_sale_aplicoop/tests/test_pricing_with_pricelist.py
index f992915..8eb4114 100644
--- a/website_sale_aplicoop/tests/test_pricing_with_pricelist.py
+++ b/website_sale_aplicoop/tests/test_pricing_with_pricelist.py
@@ -16,6 +16,8 @@ Coverage:
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
+from ..controllers.website_sale import AplicoopWebsiteSale
+
@tagged("post_install", "-at_install")
class TestPricingWithPricelist(TransactionCase):
@@ -49,6 +51,7 @@ class TestPricingWithPricelist(TransactionCase):
"company_ids": [(6, 0, [self.company.id])],
}
)
+ self.partner = self.user.partner_id
# Get or create default tax group
tax_group = self.env["account.tax.group"].search(
@@ -161,6 +164,9 @@ class TestPricingWithPricelist(TransactionCase):
}
)
+ # Controller helper
+ self.controller = AplicoopWebsiteSale()
+
# Create group order
self.group_order = self.env["group.order"].create(
{
@@ -493,3 +499,52 @@ class TestPricingWithPricelist(TransactionCase):
except Exception:
# If it raises, that's also acceptable behavior
self.assertTrue(True, "Negative quantity properly rejected")
+
+ def test_pricing_helper_uses_config_pricelist_and_taxes(self):
+ """Pricing helper must apply configured pricelist and include taxes for display."""
+
+ website = self.env.ref("website.default_website").sudo()
+ website.write(
+ {
+ "show_line_subtotals_tax_selection": "tax_included",
+ "company_id": self.company.id,
+ }
+ )
+
+ pricelist_discount = self.env["product.pricelist"].create(
+ {
+ "name": "Config Pricelist 10%",
+ "company_id": self.company.id,
+ "item_ids": [
+ (
+ 0,
+ 0,
+ {
+ "compute_price": "percentage",
+ "percent_price": 10.0,
+ "applied_on": "3_global",
+ },
+ )
+ ],
+ }
+ )
+
+ self.env["ir.config_parameter"].sudo().set_param(
+ "website_sale_aplicoop.pricelist_id", pricelist_discount.id
+ )
+
+ product = self.product_with_tax # 100€ + 21%
+
+ pricing = self.controller._get_pricing_info(
+ product,
+ pricelist_discount,
+ quantity=1.0,
+ partner=self.partner,
+ )
+
+ # price_unit uses pricelist (10% discount)
+ self.assertAlmostEqual(pricing["price_unit"], 90.0, places=2)
+ # display price must include taxes (21% on discounted price)
+ self.assertAlmostEqual(pricing["price"], 108.9, places=2)
+ self.assertTrue(pricing.get("tax_included"))
+ self.assertTrue(pricing.get("has_discounted_price"))
diff --git a/website_sale_aplicoop/tests/test_save_order_endpoints.py b/website_sale_aplicoop/tests/test_save_order_endpoints.py
index 4e975fe..7a559db 100644
--- a/website_sale_aplicoop/tests/test_save_order_endpoints.py
+++ b/website_sale_aplicoop/tests/test_save_order_endpoints.py
@@ -16,6 +16,8 @@ from datetime import timedelta
from odoo.tests.common import TransactionCase
+from ..controllers.website_sale import AplicoopWebsiteSale
+
class TestSaveOrderEndpoints(TransactionCase):
"""Test suite for order-saving endpoints."""
@@ -94,6 +96,51 @@ class TestSaveOrderEndpoints(TransactionCase):
# Associate product with group order
self.group_order.product_ids = [(4, self.product.id)]
+ # Helper: controller instance for pure helpers
+ self.controller = AplicoopWebsiteSale()
+
+ def _build_line(self, product, qty, price):
+ return (
+ 0,
+ 0,
+ {"product_id": product.id, "product_uom_qty": qty, "price_unit": price},
+ )
+
+ def test_merge_or_replace_replaces_existing_draft(self):
+ """Existing draft must be replaced (not merged) with new lines."""
+
+ # Existing draft with one line
+ existing = self.env["sale.order"].create(
+ {
+ "partner_id": self.member_partner.id,
+ "group_order_id": self.group_order.id,
+ "pickup_day": self.group_order.pickup_day,
+ "pickup_date": self.group_order.pickup_date,
+ "home_delivery": self.group_order.home_delivery,
+ "order_line": [self._build_line(self.product, 1, 10.0)],
+ "state": "draft",
+ }
+ )
+
+ new_lines = [self._build_line(self.product, 3, 99.0)]
+
+ result = self.controller._merge_or_replace_draft(
+ self.group_order,
+ self.user,
+ new_lines,
+ existing,
+ self.group_order.id,
+ )
+
+ self.assertEqual(result.id, existing.id, "Should reuse existing draft")
+ self.assertEqual(len(result.order_line), 1, "Only one line should remain")
+ self.assertEqual(
+ result.order_line[0].product_uom_qty, 3, "Quantity must be replaced"
+ )
+ self.assertEqual(
+ result.order_line[0].price_unit, 99.0, "Price must be replaced"
+ )
+
def test_save_eskaera_draft_creates_order_with_group_order_id(self):
"""
Test that save_eskaera_draft() creates a sale.order with group_order_id.
diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml
index c7964df..1300fe1 100644
--- a/website_sale_aplicoop/views/website_templates.xml
+++ b/website_sale_aplicoop/views/website_templates.xml
@@ -349,6 +349,11 @@
+
+
+
@@ -518,16 +523,6 @@
-
-
-
- Important:
-
-
- Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.
-
-
-