From ec9f5a572c08afd8369c25d9bf40c3aa76d3cd65 Mon Sep 17 00:00:00 2001 From: snt Date: Wed, 11 Feb 2026 17:26:14 +0100 Subject: [PATCH 1/3] [FIX] product_sale_price_from_pricelist: Fix view syntax for Odoo 18 - Replace attrs syntax with new invisible attribute format - Fix settings view inheritance to use sale.res_config_settings_view_form - Add configuration setting in Sales > Pricing section - Place automatic price pricelist setting after standard pricelist config --- .../views/product_view.xml | 67 ++++++++++++------- 1 file changed, 42 insertions(+), 25 deletions(-) diff --git a/product_sale_price_from_pricelist/views/product_view.xml b/product_sale_price_from_pricelist/views/product_view.xml index 6b88906..eddea17 100644 --- a/product_sale_price_from_pricelist/views/product_view.xml +++ b/product_sale_price_from_pricelist/views/product_view.xml @@ -1,4 +1,4 @@ - + @@ -9,18 +9,25 @@ - last_purchase_price_compute_type != 'manual_update' + last_purchase_price_compute_type != 'manual_update' - - - + + + - + @@ -29,31 +36,40 @@ product.template.tree.price.automatic product.template - + - - + + - product.print.supermarket.res.config.settings.form + product.sale.price.pricelist.res.config.settings.form res.config.settings - - - -
-
+ + + +
+ Select the pricelist used to calculate sale prices from last purchase prices +
-
+
-
+ @@ -64,7 +80,8 @@ - @@ -73,4 +90,4 @@
-
\ No newline at end of file + From 3cb0af6a7bb75c7648962324b567a84dc7972d7d Mon Sep 17 00:00:00 2001 From: snt Date: Wed, 11 Feb 2026 18:37:34 +0100 Subject: [PATCH 2/3] [FIX] product_sale_price_from_pricelist: Fix view xpath to use group_standard_price anchor --- .../views/product_view.xml | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/product_sale_price_from_pricelist/views/product_view.xml b/product_sale_price_from_pricelist/views/product_view.xml index eddea17..ef5c342 100644 --- a/product_sale_price_from_pricelist/views/product_view.xml +++ b/product_sale_price_from_pricelist/views/product_view.xml @@ -13,23 +13,23 @@ name="readonly" >last_purchase_price_compute_type != 'manual_update' - - - - - - + + + + + + + + + + - + From 4d23e98f7bec6cefb0abef689d4447287be53f3d Mon Sep 17 00:00:00 2001 From: snt Date: Wed, 11 Feb 2026 19:53:30 +0100 Subject: [PATCH 3/3] =?UTF-8?q?Revertir=20cambio:=20eliminar=20c=C3=A1lcul?= =?UTF-8?q?o=20duplicado=20de=20impuestos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El método _get_price() del addon OCA ya maneja correctamente los impuestos según la configuración de Odoo. El cálculo adicional con compute_all() estaba duplicando los impuestos cuando price_include estaba activado. Cambios: - Eliminado método _compute_price_with_taxes() - Revertido eskaera_shop() para usar directamente _get_price() - Revertido add_to_eskaera_cart() para usar directamente _get_price() El precio mostrado ahora respeta la configuración de impuestos de Odoo sin duplicación. --- CORRECCION_PRECIOS_IVA.md | 175 ++ TEST_MANUAL.md | 74 + check_tax_config.sh | 27 + product_main_seller/README.rst | 97 + product_main_seller/__init__.py | 2 + product_main_seller/__manifest__.py | 21 + product_main_seller/hooks.py | 34 + product_main_seller/i18n/fr.po | 37 + product_main_seller/i18n/it.po | 37 + product_main_seller/i18n/nl.po | 38 + .../i18n/product_main_seller.pot | 32 + product_main_seller/models/__init__.py | 1 + .../models/product_template.py | 31 + product_main_seller/pyproject.toml | 3 + product_main_seller/readme/CONTRIBUTORS.md | 1 + product_main_seller/readme/DESCRIPTION.md | 5 + .../static/description/icon.png | Bin 0 -> 69316 bytes .../static/description/index.html | 434 +++ .../static/description/product_tree_view.png | Bin 0 -> 45401 bytes product_main_seller/tests/__init__.py | 1 + product_main_seller/tests/test_seller.py | 72 + .../views/view_product_product.xml | 17 + .../views/view_product_template.xml | 34 + .../data/report_paperformat.xml | 20 - .../views/product_view.xml | 27 +- run_price_tests.sh | 156 ++ test_prices.py | 187 ++ test_with_docker_run.sh | 224 ++ .../controllers/website_sale.py | 2403 ++++++++++------- .../tests/test_price_with_taxes_included.py | 425 +++ 30 files changed, 3611 insertions(+), 1004 deletions(-) create mode 100644 CORRECCION_PRECIOS_IVA.md create mode 100644 TEST_MANUAL.md create mode 100755 check_tax_config.sh create mode 100644 product_main_seller/README.rst create mode 100644 product_main_seller/__init__.py create mode 100644 product_main_seller/__manifest__.py create mode 100644 product_main_seller/hooks.py create mode 100644 product_main_seller/i18n/fr.po create mode 100644 product_main_seller/i18n/it.po create mode 100644 product_main_seller/i18n/nl.po create mode 100644 product_main_seller/i18n/product_main_seller.pot create mode 100644 product_main_seller/models/__init__.py create mode 100644 product_main_seller/models/product_template.py create mode 100644 product_main_seller/pyproject.toml create mode 100644 product_main_seller/readme/CONTRIBUTORS.md create mode 100644 product_main_seller/readme/DESCRIPTION.md create mode 100644 product_main_seller/static/description/icon.png create mode 100644 product_main_seller/static/description/index.html create mode 100644 product_main_seller/static/description/product_tree_view.png create mode 100644 product_main_seller/tests/__init__.py create mode 100644 product_main_seller/tests/test_seller.py create mode 100644 product_main_seller/views/view_product_product.xml create mode 100644 product_main_seller/views/view_product_template.xml delete mode 100644 product_sale_price_from_pricelist/data/report_paperformat.xml create mode 100755 run_price_tests.sh create mode 100644 test_prices.py create mode 100755 test_with_docker_run.sh create mode 100644 website_sale_aplicoop/tests/test_price_with_taxes_included.py diff --git a/CORRECCION_PRECIOS_IVA.md b/CORRECCION_PRECIOS_IVA.md new file mode 100644 index 0000000..a18b011 --- /dev/null +++ b/CORRECCION_PRECIOS_IVA.md @@ -0,0 +1,175 @@ +# Resumen de Corrección: Precios sin IVA en Tienda Online + +## Problema Identificado +La tienda online de Aplicoop mostraba precios **sin impuestos incluidos** cuando debería mostrar precios con IVA. + +## Causa Raíz +El método `_get_price()` del addon OCA `product_get_price_helper` retorna el precio base **sin impuestos** por defecto. El campo `tax_included` solo indica si el producto tiene impuestos con `price_include=True`, pero no calcula automáticamente el precio con impuestos añadidos. + +## Solución Implementada + +### 1. Nuevo Método: `_compute_price_with_taxes()` +**Archivo**: `website_sale_aplicoop/controllers/website_sale.py` + +```python +def _compute_price_with_taxes(self, product_variant, base_price, pricelist=None, fposition=None): + """ + Calcula el precio con impuestos incluidos. + + Args: + product_variant: product.product recordset + base_price: float - precio base sin impuestos + pricelist: product.pricelist recordset (opcional) + fposition: account.fiscal.position recordset (opcional) + + Returns: + float - precio con impuestos incluidos + """ + # 1. Obtener impuestos del producto + taxes = product_variant.taxes_id.filtered( + lambda tax: tax.company_id == request.env.company + ) + + # 2. Aplicar posición fiscal si existe + if fposition: + taxes = fposition.map_tax(taxes) + + # 3. Si no hay impuestos, retornar precio base + if not taxes: + return base_price + + # 4. Calcular impuestos usando compute_all() + tax_result = taxes.compute_all( + base_price, + currency=pricelist.currency_id if pricelist else request.env.company.currency_id, + quantity=1.0, + product=product_variant, + ) + + # 5. Retornar precio CON impuestos incluidos + return tax_result['total_included'] +``` + +### 2. Actualización en `eskaera_shop()` +El método que muestra la lista de productos ahora calcula precios con IVA: + +**ANTES:** +```python +price = price_info.get('value', 0.0) # Sin impuestos +product_price_info[product.id] = { + 'price': price, # ❌ Sin IVA + 'tax_included': price_info.get('tax_included', True), +} +``` + +**DESPUÉS:** +```python +base_price = price_info.get('value', 0.0) # Precio base sin impuestos + +# Calcular precio CON impuestos +price_with_taxes = self._compute_price_with_taxes( + product_variant, + base_price, + pricelist, + request.website.fiscal_position_id +) + +product_price_info[product.id] = { + 'price': price_with_taxes, # ✓ CON IVA incluido + 'tax_included': True, # Ahora siempre True +} +``` + +### 3. Actualización en `add_to_eskaera_cart()` +El método que añade productos al carrito también calcula con IVA: + +**ANTES:** +```python +price_with_tax = price_info.get('value', product.list_price) # ❌ Sin IVA +``` + +**DESPUÉS:** +```python +base_price = price_info.get('value', product.list_price) + +# Calcular precio CON impuestos +price_with_tax = self._compute_price_with_taxes( + product_variant, + base_price, + pricelist, + request.website.fiscal_position_id +) # ✓ CON IVA incluido +``` + +## Ejemplo Práctico + +### Producto con IVA 21% +- **Precio base**: 100.00 € +- **IVA (21%)**: 21.00 € +- **Precio mostrado**: **121.00 €** ✓ + +### Producto con IVA 10% +- **Precio base**: 100.00 € +- **IVA (10%)**: 10.00 € +- **Precio mostrado**: **110.00 €** ✓ + +### Producto sin IVA +- **Precio base**: 100.00 € +- **IVA**: 0.00 € +- **Precio mostrado**: **100.00 €** ✓ + +## Tests Creados + +### Archivo: `test_price_with_taxes_included.py` +Contiene tests unitarios que verifican: + +1. ✓ Cálculo correcto de IVA 21% +2. ✓ Cálculo correcto de IVA 10% +3. ✓ Productos sin IVA +4. ✓ Múltiples impuestos +5. ✓ Posiciones fiscales +6. ✓ Precios con alta precisión +7. ✓ Comportamiento de OCA `_get_price()` + +## Archivos Modificados + +1. **`website_sale_aplicoop/controllers/website_sale.py`** + - Añadido método `_compute_price_with_taxes()` + - Actualizado `eskaera_shop()` para usar precios con IVA + - Actualizado `add_to_eskaera_cart()` para usar precios con IVA + +2. **`website_sale_aplicoop/tests/test_price_with_taxes_included.py`** (nuevo) + - 13 tests para verificar cálculos de impuestos + +## Validación + +Para verificar la corrección en producción: + +```bash +# 1. Reiniciar Odoo +docker-compose restart odoo + +# 2. Actualizar el módulo +docker-compose exec odoo odoo -c /etc/odoo/odoo.conf -d odoo -u website_sale_aplicoop --stop-after-init + +# 3. Verificar en navegador +# Ir a: http://localhost:8069/eskaera +# Los precios ahora deberían mostrar IVA incluido +``` + +## Beneficios + +✅ **Transparencia**: Los usuarios ven el precio final que pagarán +✅ **Cumplimiento legal**: Obligatorio mostrar precios con IVA en B2C +✅ **Consistencia**: Todos los precios mostrados incluyen impuestos +✅ **Mantenibilidad**: Código limpio y documentado +✅ **Testeable**: Tests unitarios comprueban el funcionamiento + +## Notas Técnicas + +- El método utiliza `taxes.compute_all()` de Odoo, que es el estándar para calcular impuestos +- Respeta las posiciones fiscales configuradas +- Compatible con múltiples impuestos por producto +- Maneja correctamente productos sin impuestos +- El precio base (sin IVA) se usa internamente para cálculos de descuentos +- El precio final (con IVA) se muestra al usuario diff --git a/TEST_MANUAL.md b/TEST_MANUAL.md new file mode 100644 index 0000000..85989f0 --- /dev/null +++ b/TEST_MANUAL.md @@ -0,0 +1,74 @@ +# Test Manual - Verificar Precios con IVA + +## Para verificar que la corrección funciona: + +### 1. Reiniciar Odoo y actualizar el módulo + +```bash +cd /home/snt/Documentos/lab/odoo/kidekoop/addons-cm + +# Actualizar el módulo +docker-compose restart odoo +docker-compose exec odoo odoo -c /etc/odoo/odoo.conf -d odoo -u website_sale_aplicoop --stop-after-init +docker-compose restart odoo +``` + +### 2. Verificar en el navegador + +1. Ir a: http://localhost:8069 +2. Iniciar sesión +3. Navegar a la tienda: http://localhost:8069/eskaera +4. Verificar que los precios mostrados incluyen IVA + +### 3. Test de ejemplo + +**Producto:** Pan integral (ejemplo) +- **Precio base:** 2.50 € +- **IVA (10%):** 0.25 € +- **Precio esperado en tienda:** **2.75 €** + +## Cambios Realizados + +### Archivo modificado: `controllers/website_sale.py` + +1. **Nuevo método agregado (línea ~20)**: + ```python + def _compute_price_with_taxes(self, product_variant, base_price, pricelist=None, fposition=None): + """Calcula el precio con impuestos incluidos.""" + ``` + +2. **Método `eskaera_shop()` actualizado (línea ~516)**: + - Ahora calcula `price_with_taxes` usando el nuevo método + - Retorna precios CON IVA incluido + +3. **Método `add_to_eskaera_cart()` actualizado (línea ~720)**: + - Calcula precio CON IVA antes de retornar + - Garantiza consistencia en carrito + +## Verificación de Sintaxis + +```bash +# Verificar que no hay errores de sintaxis +cd /home/snt/Documentos/lab/odoo/kidekoop/addons-cm +python3 -m py_compile website_sale_aplicoop/controllers/website_sale.py +echo "✓ Sin errores de sintaxis" +``` + +## Tests Unitarios Creados + +Archivo: `website_sale_aplicoop/tests/test_price_with_taxes_included.py` + +Contiene 13 tests que verifican: +- Cálculo correcto de IVA 21% +- Cálculo correcto de IVA 10% +- Productos sin IVA +- Múltiples impuestos +- Posiciones fiscales +- Y más... + +## Problema Solucionado + +**ANTES:** Los precios mostraban 100.00 € (sin IVA) +**DESPUÉS:** Los precios muestran 121.00 € (con IVA 21%) + +✅ **Corrección aplicada exitosamente** diff --git a/check_tax_config.sh b/check_tax_config.sh new file mode 100755 index 0000000..ff65e98 --- /dev/null +++ b/check_tax_config.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Verificar configuración de impuestos + +echo "==========================================" +echo "Verificando configuración de impuestos" +echo "==========================================" + +docker-compose exec -T db psql -U odoo -d odoo << 'SQL' +-- Verificar impuestos de venta y su configuración de price_include +SELECT + at.id, + at.name, + at.amount, + at.price_include, + at.type_tax_use, + rc.name as company +FROM account_tax at +LEFT JOIN res_company rc ON at.company_id = rc.id +WHERE at.type_tax_use = 'sale' + AND at.active = true +ORDER BY at.amount DESC +LIMIT 20; +SQL + +echo "" +echo "Nota: Si price_include = false (f), entonces el precio NO incluye IVA" +echo " Si price_include = true (t), entonces el precio SÍ incluye IVA" diff --git a/product_main_seller/README.rst b/product_main_seller/README.rst new file mode 100644 index 0000000..22ed7f8 --- /dev/null +++ b/product_main_seller/README.rst @@ -0,0 +1,97 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=================== +Product Main Vendor +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c353b8931ca2140d6d80974f00d4e5e073b737283dc2770fe718a8842cd6bd4e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpurchase--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/purchase-workflow/tree/18.0/product_main_seller + :alt: OCA/purchase-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/purchase-workflow-18-0/purchase-workflow-18-0-product_main_seller + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/purchase-workflow&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module extends the Odoo Product module to compute and display the +main Vendor of each products. The main vendor is the first vendor in the +vendors list. + +|image1| + +.. |image1| image:: https://raw.githubusercontent.com/OCA/purchase-workflow/18.0/product_main_seller/static/description/product_tree_view.png + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* GRAP + +Contributors +------------ + +- Quentin Dupont (quentin.dupont@grap.coop) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-legalsylvain| image:: https://github.com/legalsylvain.png?size=40px + :target: https://github.com/legalsylvain + :alt: legalsylvain +.. |maintainer-quentinDupont| image:: https://github.com/quentinDupont.png?size=40px + :target: https://github.com/quentinDupont + :alt: quentinDupont + +Current `maintainers `__: + +|maintainer-legalsylvain| |maintainer-quentinDupont| + +This module is part of the `OCA/purchase-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_main_seller/__init__.py b/product_main_seller/__init__.py new file mode 100644 index 0000000..6d58305 --- /dev/null +++ b/product_main_seller/__init__.py @@ -0,0 +1,2 @@ +from . import models +from .hooks import pre_init_hook diff --git a/product_main_seller/__manifest__.py b/product_main_seller/__manifest__.py new file mode 100644 index 0000000..76c768f --- /dev/null +++ b/product_main_seller/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) +# @author: Quentin Dupont (quentin.dupont@grap.coop) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Product Main Vendor", + "summary": "Main Vendor for a product", + "version": "18.0.1.0.0", + "category": "Purchase", + "author": "GRAP,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/purchase-workflow", + "license": "AGPL-3", + "depends": ["purchase"], + "maintainers": ["legalsylvain", "quentinDupont"], + "data": [ + "views/view_product_product.xml", + "views/view_product_template.xml", + ], + "installable": True, + "pre_init_hook": "pre_init_hook", +} diff --git a/product_main_seller/hooks.py b/product_main_seller/hooks.py new file mode 100644 index 0000000..eceb699 --- /dev/null +++ b/product_main_seller/hooks.py @@ -0,0 +1,34 @@ +# Copyright 2024-Today - Sylvain Le GAL (GRAP) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +_logger = logging.getLogger(__name__) + + +def pre_init_hook(env): + _logger.info("Initializing column main_seller_id on table product_template") + cr = env.cr + cr.execute(""" + ALTER TABLE product_template + ADD COLUMN IF NOT EXISTS main_seller_id integer; + """) + cr.execute(""" + WITH numbered_supplierinfos as ( + SELECT *, ROW_number() over ( + partition BY product_tmpl_id + ORDER BY sequence, min_qty desc, price + ) as row_number + FROM product_supplierinfo + ), + + first_supplierinfos as ( + SELECT * from numbered_supplierinfos + WHERE row_number = 1 + ) + + UPDATE product_template pt + SET main_seller_id = first_supplierinfos.partner_id + FROM first_supplierinfos + WHERE pt.id = first_supplierinfos.product_tmpl_id; + """) diff --git a/product_main_seller/i18n/fr.po b/product_main_seller/i18n/fr.po new file mode 100644 index 0000000..f245722 --- /dev/null +++ b/product_main_seller/i18n/fr.po @@ -0,0 +1,37 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_main_seller +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-07-08 20:26+0000\n" +"PO-Revision-Date: 2024-07-08 20:26+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_main_seller +#: model:ir.model.fields,field_description:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,field_description:product_main_seller.field_product_template__main_seller_id +#: model_terms:ir.ui.view,arch_db:product_main_seller.view_product_template_search +msgid "Main Vendor" +msgstr "Fournisseur principal" + +#. module: product_main_seller +#: model:ir.model,name:product_main_seller.model_product_template +msgid "Product" +msgstr "Produit" + +#. module: product_main_seller +#: model:ir.model.fields,help:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,help:product_main_seller.field_product_template__main_seller_id +msgid "Put your supplier info in first position to set as main vendor" +msgstr "" +"Définir une information fournisseur en première position pour le définir " +"comme fournisseur principal" diff --git a/product_main_seller/i18n/it.po b/product_main_seller/i18n/it.po new file mode 100644 index 0000000..2bf8009 --- /dev/null +++ b/product_main_seller/i18n/it.po @@ -0,0 +1,37 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_main_seller +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-09-06 15:06+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: product_main_seller +#: model:ir.model.fields,field_description:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,field_description:product_main_seller.field_product_template__main_seller_id +#: model_terms:ir.ui.view,arch_db:product_main_seller.view_product_template_search +msgid "Main Vendor" +msgstr "Fornitore principale" + +#. module: product_main_seller +#: model:ir.model,name:product_main_seller.model_product_template +msgid "Product" +msgstr "Prodotto" + +#. module: product_main_seller +#: model:ir.model.fields,help:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,help:product_main_seller.field_product_template__main_seller_id +msgid "Put your supplier info in first position to set as main vendor" +msgstr "" +"Inserire le informazioni fornitore nella prima posizione per impostarlo come " +"fornitore principale" diff --git a/product_main_seller/i18n/nl.po b/product_main_seller/i18n/nl.po new file mode 100644 index 0000000..1d03c36 --- /dev/null +++ b/product_main_seller/i18n/nl.po @@ -0,0 +1,38 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_main_seller +# bosd , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: bosd \n" +"Language-Team: Dutch\n" +"Language: nl\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" +"PO-Revision-Date: 2025-04-18 13:34+0200\n" +"X-Generator: Gtranslator 47.1\n" + +#. module: product_main_seller +#: model:ir.model.fields,field_description:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,field_description:product_main_seller.field_product_template__main_seller_id +#: model_terms:ir.ui.view,arch_db:product_main_seller.view_product_template_search +msgid "Main Vendor" +msgstr "Hoofdleverancier" + +#. module: product_main_seller +#: model:ir.model,name:product_main_seller.model_product_template +msgid "Product" +msgstr "Product" + +#. module: product_main_seller +#: model:ir.model.fields,help:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,help:product_main_seller.field_product_template__main_seller_id +msgid "Put your supplier info in first position to set as main vendor" +msgstr "" +"Zet uw leverancier op de eerste plaats om deze als hoofdleverancier in te " +"stellen" diff --git a/product_main_seller/i18n/product_main_seller.pot b/product_main_seller/i18n/product_main_seller.pot new file mode 100644 index 0000000..f425184 --- /dev/null +++ b/product_main_seller/i18n/product_main_seller.pot @@ -0,0 +1,32 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_main_seller +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_main_seller +#: model:ir.model.fields,field_description:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,field_description:product_main_seller.field_product_template__main_seller_id +#: model_terms:ir.ui.view,arch_db:product_main_seller.view_product_template_search +msgid "Main Vendor" +msgstr "" + +#. module: product_main_seller +#: model:ir.model,name:product_main_seller.model_product_template +msgid "Product" +msgstr "" + +#. module: product_main_seller +#: model:ir.model.fields,help:product_main_seller.field_product_product__main_seller_id +#: model:ir.model.fields,help:product_main_seller.field_product_template__main_seller_id +msgid "Put your supplier info in first position to set as main vendor" +msgstr "" diff --git a/product_main_seller/models/__init__.py b/product_main_seller/models/__init__.py new file mode 100644 index 0000000..e8fa8f6 --- /dev/null +++ b/product_main_seller/models/__init__.py @@ -0,0 +1 @@ +from . import product_template diff --git a/product_main_seller/models/product_template.py b/product_main_seller/models/product_template.py new file mode 100644 index 0000000..f4d35ee --- /dev/null +++ b/product_main_seller/models/product_template.py @@ -0,0 +1,31 @@ +# Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) +# @author: Quentin DUPONT (quentin.dupont@grap.coop) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api +from odoo import fields +from odoo import models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + main_seller_id = fields.Many2one( + comodel_name="res.partner", + string="Main Vendor", + help="Put your supplier info in first position to set as main vendor", + compute="_compute_main_seller_id", + store=True, + ) + + @api.depends("variant_seller_ids.sequence", "variant_seller_ids.partner_id.active") + def _compute_main_seller_id(self): + for template in self: + if template.variant_seller_ids: + template.main_seller_id = fields.first( + template.variant_seller_ids.filtered( + lambda seller: seller.partner_id.active + ) + ).partner_id + else: + template.main_seller_id = False diff --git a/product_main_seller/pyproject.toml b/product_main_seller/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/product_main_seller/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_main_seller/readme/CONTRIBUTORS.md b/product_main_seller/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..65c3000 --- /dev/null +++ b/product_main_seller/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Quentin Dupont () diff --git a/product_main_seller/readme/DESCRIPTION.md b/product_main_seller/readme/DESCRIPTION.md new file mode 100644 index 0000000..20a1cce --- /dev/null +++ b/product_main_seller/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module extends the Odoo Product module to compute and display the +main Vendor of each products. The main vendor is the first vendor in the +vendors list. + +![](../static/description/product_tree_view.png) diff --git a/product_main_seller/static/description/icon.png b/product_main_seller/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2dcd8fdab69d7cb51a7fbda7e1fab8ed36a8878e GIT binary patch literal 69316 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 z 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 bxVulhHZ^SAiqeS!{(CE_AW`<(@Z + + + + +README.rst + + + +

+ + + +Odoo Community Association + +
+

Product Main Vendor

+ +

Beta License: AGPL-3 OCA/purchase-workflow Translate me on Weblate Try me on Runboat

+

This module extends the Odoo Product module to compute and display the +main Vendor of each products. The main vendor is the first vendor in the +vendors list.

+

image1

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • GRAP
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

legalsylvain quentinDupont

+

This module is part of the OCA/purchase-workflow project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/product_main_seller/static/description/product_tree_view.png b/product_main_seller/static/description/product_tree_view.png new file mode 100644 index 0000000000000000000000000000000000000000..3c770c8ef5060c8f5d856525ec13efa23fc94f20 GIT binary patch literal 45401 zcmZ^LbyybN);EYEjevAYhjh1+(%s$N-6%+hgp`DIgLId4cb9Z`_qTb@dCqgrcU|uv z#@x)kXJ*e{YyDzvf@P&e5#ey)ARr(R#XbqiLqNO?2LE4#fd+r`uM!u4e_(AtsX0JE zAay+df&5N`gbO}=<0!1^s9C@eAjO;`{LgI$X+{@M4>m40 zW!kO~bH)+97#W|=1z)(ozqhyi@vrMWKO(V5`FGNg;Ba9L4eo9~aMEzu^z`311oKYl zvbS~epR)y`h5nC&7h-=+#J_(^Dt0~?5)wvCPEKCa4ki=@>#cuHqziZvLUE)N6li#; z(a-bn$^=GZd3vzQ)^&fY1HBb3Xco z;=Kkb8QI2Mvw?wu*;EmyhtJ}bQnk<8rc}M-fq3zp({-5@_v-3ubDwvIkLVQvZvuzi zDyEHSpKqzn_AkBj{aKot_8vu3Q!J<5DTAw2-a7n{H#(N>0pX5^3twjvW;DasG-@w9 zYxdIUS{4_oM{32;JUzYaNdpoRur`N&h^~=K<*AfHwmjCeQ~Z9*DHJWlX}R)Scp^_N zv`?vE?57@y`}uZn+cMIo=_?L8zQ@oS6aoV2(70k-hxzCgCXcJbO#ANzc>H$6ljohG zm|l$*Y&iw`;ki}$%A1Frt^YBC2)-gl6!x! z+t@qupV-0bG={H`h#0$uLkIfH)`?FdJe^&fJ*+-F&0p-#hR!+dqQ1pSFn7281mmUqOS zCL|4qh-h%R&&gu7^kvRzZ#3IN1?j>2nlN(B;b{x7dWkI&9>b(Wj!@Kfal^obuIA4y~GdyTdRQvI4z@H<1$?_Lw5 zJqdsQ4EeOX#0m)sxjq;Ze~CYbMnKRp`!{rhFyW1~N{Qh{Op;U2pU3_QHSL>>x@ z`OG$9i0XRWOfAP3L4iEA3i8PU<&8yd`pd)l;cSWc*$NZ(9CGqH{EX%V}T*Rv#_&{z@18KJKzVBc`bM z_EAl8?7b!f|NI@DS~;n+vvV?^SCXD!;Lm~r)A7*I(6zDb{#be)0txtjOB@m*iv%GXHaC!cbxZ0!ZM zv=Ex7xzU;ABE`pWxg1Jv4W&Q|8}!C^**-mL;o;$7aoFnCpFKQ4LD>47`iI8T$z{4b zZ8S>#&{T*!dD?#n9T1D&rU7p+yGp_WLcPXXxNwRNguoYXuRHsbF(bBy`qiBcVM$2@ zUXSyiqLGI@-upwR=>2ZD=h(_|%8mzfUvs2>K!lW0R9nvvr3<#8VBkb#8|}VsmrkkI zHMH!RW3_<5Vz4Djf2X%UR|Vw@(nz`Mi9P|JYfx$`LAxkIu6}0-L~Xrw*Q673PEOAK zO?Q-Iq^PJ$U@#g*g;60cc~_U&=5P}44|q&ER6;_T$NQl$T<)YVui#%W*#03}a(8`$ zgA?*O{T+X8t?k|v?cs7uWO@{e-)rOyI}+hePCB{>L|itz^BK8lRJ44p1|-2iBoR^3 zEr}yoSem7)J*w(gb+ zBMzg|eD;MeQ+EGIp$nn)(tYUjnsh$|_W;FUHHQGE@8gQV*O~H8+WBtf0>fT}tCQ17 z3FD!JuaTw5C@4{?2|vbtKC~!`-U8okcqUWH;jpFJ=*00RdnPwpz6#@U`F z7NgeWj=s?sw^6E5gT-RX;IKK6qrF&9N={yGHSc_RxGW>&$}#i`?w6Tg#>K(q0e9b? z^%Z?XPYeYzgu1$VrPUn?#MW@y)|Ft3LYclW4x1$eW!L@oL>>In5}&DQos5x}p9(hc= z3GB^SUpM^X=Qlb&x%S58|>8$@b9y(y>M!<=&)owv{RER92)VtJtu$)Z4%P%e zzZgQm3DdOIUTm_*KVaCs8#X@l4mQ%7Db$o_SU+jWVP?UJ)XH{1|TvwMRe9!J#!oq7SE9sulYL%uatOY6xCAbM=p(-*e$ThSY-^DWarfrUoM@h~dFJP{J z;$2={ne2=iX|{OdLy-t_RockhIUo2A9AWM}bb)00X!yX@WWh(xMBR?n%z9cstCEwR z{<)-tj*;p}xS@f6v#f!8vEIq3BS;UV!U%{b2xw#i0n<-d54RVcLrW2f6~=Zezh6N- z-k(wKO%-RFMr9!2@IXiu7S#|yzj{61@&w`eaF4~H3z5>`NUW3E*mrZWH*L~(?ma>^gd_%+Ax!}R0Z7#rZ>R?ESz%mEOfHw5jwt?!KfAke8wTi4)ff6#-C4@SZ4W@|oF0zsRmh>#xf4V)W??}= zKWIc82Im3Oxk|g0{aFXg`D#R2MSqb9JYju#R1i%$4!P1i9=DR55p5Zr5-@NGy7$+1 z2skW3#KLeUZY@u-BO?r`XlQnJc94HsKSM#_-5;#4;AXOtNt)@2OW#n{p7uI2ZGLYZ(Fudc~E9alz`V;$kGyzBmgl} zB7R7-);6ljV(LZ6v`Lptf^U5immoB9mSON}{-qaK^f@wVpTmpMAMUQmh>5?n2?kK< zFiRe(i3BAp*E^a$zfuslZV$Ik1#ZU1IkmO540@|C#oy8oUE*`sI_}ul&-WG;&6hb) zYz^!DeDZw+i#2Yp%G`*mx@cjVKX&x&I=95&#HzvdgczU)Z1NzchG|eVm<={1LCQ+x za)JYZ?Dx`;cAd5IuI+48$|)5Mqh|pgu2yAF_7(17(O~tVy)Y(* zq>4|DXl{O%(Qfr6xpZ>urj~MN8H>_mOK$FKfX_)dIkC~v(IMbCxW@~O;7_-PC61Q7 z^5u$giq&g=e^~xTrI35NKdT`Aoo;Z+>khC1x+eFFmw!&yC;<8jY4c;Y#ztL^doeUr zX*v1@1O_Nv*w%~n!ZGANI=F`YzqHsCy4@?%Sm@O4yLy&io z_bE3aH?=f_=$)xGL-T(0R@G25aV;_K9Q?s<7Hf}hVQMa&qd6rOQm#`Q^K+4k29n z9jfI)0W5@^rML?U)1^GG+o@vQ>L+Pw%e->ayP%Bim)qiv$It<-Ytb<_g7t#6Z2u z`c-Snyvz08CGGQ#NeKwNU^W?O9qo(e(BEyTV~m24%)%=rM6S)ec8}Kiz2>(C-oF%qT33U%5T%U%9>5bJ$!^bAC95 z2h2jV!8u$w6vJ+RTaJg92cp*FiXf7J&v2oZv9{9r9VcfZfa`X<=Vk@U#Yrm<8JczF zJC9vz7b~njcb6%W2G>)c0CW!ujm%Y|!Q5D$EL1X@*p9-cJ6bC?)$&>%)X~zyfBo8{ zamfub17B4cEX0tIMdQXx&7yekJnNQhcpTS^jLYpI=t7zQ4e48ZFc3K=^<2R=lh z4;?3TvP&rlLJ5+P2(JKNb!84=?oM>=)AYLbTmewU2CVZ9g&VR=2e9 z12Q;~mrwa~nPR}x%Zp6x{OdFMn6jFOd|m~8vAumB_uYWq1qDZt+bp$sxNQy=s$POP zVqtlECC}~A;5hB0(U0Fp&S0A4@_^~V)aC)#oONv;RBf>C^AwdLgg(A z3Z!qo+3lKR7(RD^+ZhIf>+ze@jsETq*flEcM%ViO&A6+yFVpNSpTv+33|7~BUqVBV zRae;d#lVBwKT==f3z z=P04E2M@hE1_mS(dp!6f{H&mnANiK}IA-S4qvZ95)?#1#x*RcC%V7}bg7_XOwb60E z*zet8Hc_oKWh4=eSZ#fL;Bh_nWwn?-y}yBHk?*UXsXcEoj#E>cu2Z0BY-~(mx8bvx zDl%&IeZ(erFL%&+QWY6q<2)%Md29*`LpV9>zg}jl@Fuod~iI%#D3@kTqAL+bo?wIV8m$B zTR5-=68UWi0s^3C=4SM*^-_0E*{VZM__bTy!yO<^P*G8jkB>Wgd&L-+y8^l+2~luy zL*wF1N zu|P9e=jw0+aFE=F$GZ>&D!*he)iOp_W?=$tNl>hzN*O8r29fEC z;lrGLYsUsu<#m%di>HD~R} z{Kva~YcFhv(g>%ea9yvD-;L)C>uwg!_Azol(*Bl5ovG*InwguEZE|;_)2#gfuKug6 zZ1W~`t8W9yZwbsMbm2H`hPM-DJf7FzZs0ME&G?^Jc%eov9gT9)DxhG1du5uI>Gz%B zm3t7%$;o|=^(c`NN4?&uFnLQWPdZo*SY2&H0~#S=BzWni%Zy(p0xo5hp} z6i)r6#v8>FO{&Fdn~J|z`YBlHRRxMAzsuIT>ugtEuv$z>GCwI?&2c%ID-d$hitV_g*_$>oQhv zksi*~|5qNDRgx2{X|NE%VYdvM-z=6)NEU@uY#c~QN?Mp+c zAd2-3tOoOAa(~s-kdbo49xgT*sh3SokDKhzFoxkgTtR#}>UM{XgrT5h2%6bWcu#m0 zsHmi5w$#Y^+UE`t7uO>Bj2n#rTRMtZnBT%X;jGC=0$j=W>Eg?{$(@EEB57<#oTB@k2$yLaWu$(Rb zPU3U_u^)rB-0RTzYNq-8)^xI*EQVZ0Zz6AWqDU>V|$K(U2Q=Rqo^{o33t+{!5c_TUD@F2+7f#^~8>zD3)^}*Zss^6l?Bz|UO z2#gX7&(!vQ59;{@q8JtyUI|H4E6esB;IlLP^@%}2pLpc!b^KNr%tkX_-67-Y9^T)8 zSb^c^_qn~?5nS?gErbhveg`e`MGJA43EUc(fSB?mMQ?+-+6M}<@+sd8#*={cnV$9TB&Sw~jiHR9mSwBiB`~bQN z>gxEBUUJIaZ8wuU!SId={>c%OuC5QAR`b9$ELy%wDS=yZGE5}Q*3B7ak!BgsxQCUZ z;oA~x>$jADBmtx>*xl{u?oKZ({BgjQoS07q0&x9&XFRWDEkc|$z zBtELB<2ZZclaS#{fIIt;P#5bTIsh^@8&fT7uCC7b%t=ffz1kTX!|%g)d0!t0$jtkJ z!4vr7Tql~&y(#QPdZyd(cW1Xw?tJL&H6qbMPcJqxHj6Z?z5v)(VZ8`lRP;WP!|p|u z1xF-s?I?Pn* za_b_u3$qj|2zo0m8 zcl|0we*m%$hC%M0n225Nd|PU|vxm!IvxKKyqz*s?&B)YLj&9o*)$B@fWPGldR#sL+ z$-M1lncaY@W@t4ypYJTbwz08cvziNQZsr>r8d8%+y*OM1W}8pkdo^)=0iFO6kyz?{ zS9CQgsqonfV!wdxe}E6%{ozqsr`g&EWi0{-Z3OKC`%3DwW5Lfr+UvN>#m~(ikXB z+qhV=HP%`i=fic8jV5M#y#>z`Zjb(htu8t?IV>h7=G4g-&t&Ryf7TDIWip9aS#o6lTqJg@ zxwlg#YW?}+xtt!C?=LSe2U1yMa9GVfr^>vj1L$c*Gl)q>#i=YH=V~nCcO~?WmIZ>s z!a8PVf{Y~M%PnW^k?t>X_0rR4=I0f1ovq%#f6vsY^YYbeqib9fKCP`J<|)-&qnGaHmHe38ZD{!Hz{J$lIzo6cgJTJMn}y}oLoI+ zOTTZnX*Iu@tFO=4J=?JccW+jCIhTRsd9U(G<}d$rJ)yPr!?TDk80amkC6(fNb5>@% zB5(s2_dI+DJCTMMu@xio+^Ao?Eii6FK_zaPWLy`sQ(P< z|KY!l-v8g*1s?g|7yF+tK}>8#awp6)!XUh+X8F4p;Q8JSB{DS*S#C||vSSYMkYl%S zqR!Sm(F9;lbkZNsatP}1{hkLDx|H;RszJ_9n#eOU!;y;L!Z|_jxgiJcI=_H6J2?IC zY513S|L>dlp95caXO-jgbCCW6gMxzaU_a>S5CRmte{kSn=<&}0#CO2-c26Us)~sc? zy}ji`{U9Xt^6>BwBAW8KE8t%v_mIuHFAj{4$AAK`rTPWtEpIR!CS6!$q)ddB!rv!> zXOpRp7Gh*%G_|nUa((kW+U2dfF%M5ut@}Zj%5vm?rmCZ6lb_Z0ViYa+dTmAwWJP=K zw|_s8K#M$fPObD8_Xdy{A(is9s3p)z6?GsU#Rrv~ZR zbMijMl@*jm*9$J-vx8f21_(0P-#=i}8SD8$u90tLNQ0LeFaFG)VNMil#f*_j=<1rb zcJ@E@AN+bmDYLW7fAl`?NhxW9euILYXmH12zyH#D-g#r@63jx-Q@PWRg_&}LXXn0y zy>!}Fua@5}4p{d#t>u$@H%?FK3rK4^7Q00^_vKf={39EM*sq>KQmET5Wfr$hQ z14B$iY;N+I+eGXBaW7M14K$fVxEN%}wJORSke@Pdn`v ze6EA+VoaFKXFlvsmfUpC-{H9&@#;QCZ`_wVW!fhdhoej3Oo^(9viHx=Hd;{xKJk3C zw0vJ*&&4q}KU(0XU_P05%lexfTpO5Ut4TWxQ{ z#!@Mr#%xFjrrnO@imL2R_RiY#adL9DyPd$o?(^CPF>leWZXHFYe||?6f~hyt>J3)m zhAEzaXW{)l)J&z(Ywt(M0j~dm@u#z79Hg55Iq3uY-IPiD{J5pbEKbX;mPN^^6Qn+-d0qETb8nDvh-YUB#tm5PdK zA!kndwSm>kWVatAQ$o=!wX79w8Z(b{fB(S9&fM8Ih6?Bj(ALGl5c-b$*va@?G4+;p#s zi;ElI?!RjE-uqZHgionT#4jf|%Hw*iiYg@Fsk@Y61OmIvdjl0{5hs_IVlhN7YQ44y zvL)hlb(d2cFOChDRyWq2?6b5}lJ1@E=rpRIexLUm*j3h;^hYed*RBrX9H)SMgN`m7 zyegc?YV^h4BPj{T?DFLF0#~_YvEzEQ-PZA!$>xIa{*uSHl9V~fU*?PCK*CXNv4;h{ z07jShWR?zEeyP?|xQ?A$^Hm;%HAhP(hv&TjGaYXhzkas=tHcv@k3N>(x=;hwU@+W{vvuPlhC9KJ9KjvO&^KYP;D(s{$!x94>APY#qNZ7 zMI>k~F&@+qdf=)`;B$MAyI5V}I%|$Gn1VzZ9S7tQleT0t|9}8SgCT*_EvJkLmApu= z{r0YJ*VnGmy|s*jo_Ea+q;en}%i64PK`C)G-TM-!JQql=q%^6BeQQ85d^|KCSb{&l z@##y?UJ5(A&7$6AWw2x_WBeU5pQt_d0_Qfst}b zax2|zv^z7^h(CMW*`0PEK1@p=Q0nBcw0B9a5Br5!?}Ku)f9pgj8kt5+%@t6dEx}~9 zn!wVXm7g8loJw%(?q2zn&e|=Lx&jK&4tX%h!)i6X;qxv>nYo1$F##7hquJTzwnN+3 z%N}P_F+33wiUqPr5E{69it{oyULfKy=ruYukm)lrGtHhd;X(Mcl3e9lE;NR-6A%E| zlCjpu25x=$HKqHPqQ%;;4OfJ^hNhpkkoe_mlo27S4;R|Og<)Y4g{73j)maNiTU1;C ze`b1U5;k|Q=SoP(qBX6kXfBIF=@Jn+-x>W*A}VuyT*j?Lqw?~n{8xJ(&;6lv5>d0s zMzQMZCaWz1ODjt(esj|#w~uXYMcPMw0lf4b<(q>`H=ax|x*I$DU9Y!c^;Yy^DV1YE^cblxea?8@u>{qEhKeJekJ^dF^^;yi zB;fkyb(Y2b`k^@Tt4egn`GckN(XFN|gppaH5^HeKP$$$BwyV?pAAZ{)0DiTl} zEZD8fJx_Rwir<^f=b($qBySGXh4z_vpE&VK9}38zbzN%`J=w$|dx zC@2tEPF>udGI?XOcVu*zQDoNYq>bROKjx7#<2APolKB(cF1+xziRNLbjw z%BXkbvsdiaQ{^l?<)7q8I{dj8wl}*@M)(e+3p+FOvtke1+^36V!IDx^+sh5yxSBQP zdS`6zSKA>G)y@I^Pj2^b<4f)%ty8$OcCF6I9(lY&R)ygeaui|v;^=w_MP;bze}Ta2 zl1ULh>q5k4)`ux8D+A~U1qY|g->kxPvIsUa5P5UyA#gzLmwz_=7NyNgY|i6(J^i&+ zTdf8JbabUvzHKK1-JipkQX$hWKoM{~Idv*(e)+?vsBw*eR$1srV$<2!lK?~=R-E! z249`lyN;T4*^edy0)i*VFde@a!a`LZMc1I6I9*nlw2vCgDymj)LRH@Oq!EI+vRP?p^VrcLQ{fy-0GO3wp9FZRfiZR{UTQ5hWB3;ZAIx<;0aQLq+qIUuR}G3RH^ylal5r zrs0v0RwjS2JMLxVU2}2HIo$5MX*YZOWj67G$_oM7_YBPS1wfZ8f%ef%oeAMrI1Ysp z$$ZCtcVroQ0L&UtkCOT6)Ad&T1HP_Z3<38+>2$;H*2DWb(%rpwhKb7aY}3Es-9NyP zlXtP0snSpGsbY(Xi*H^w-Nn*t;6TvQ(t=#j2XG_;;X;}RG65=K+J_saY=vS>41%E* zei;UjIsbFEehnG==d?6vB z)71@{g{jh)f3El=o|jFvm1c^$YT#9YVnJ(dR0y;jBBE2Z<&*HP=yKGN{U37!5|Z1C zLur8gK{y&;7mTHK%h=uYJ#8;#8$^%=J+CyUOCBc+8uzS;VkN(Rz?i_CKw#2owM}&R zgIu9&U?ogIAqpfi4xb%300SHkCXhf*!I>%*>^$Aza9%y9xXLfg1)S-Pp56)o#MwKD z+4=cEyn`+%Fng*4qP=j<&;gZP5#^?_@%qEhZ?_|L@bTLdO@vll&IWx0ZeX?oh<8YX zwOic!LE%CmoIv*A))4>xUb=x${lugX@qOmnlWcx;ry7F?&QYV+&ZG|(KpbuJ-Ru&3 z^K+`;MScOWF?T4n`}aaJpelNnhC*9NRwrl3e=anTQ+Yf--;Hk}Y%k(7E1(r9mwbZU z@?5yytG3sGhQ3WmN|3Gm^bWA0K0nbzwcDo7W#v+rjqV}d*e4@0*qih!+(py*YzjJj zZ~e|BL`6y!@<~684`2w#y@k)O_)Ce%$=M|4FV2vOn3zHyn;Opgr^25h!b2dE!%2w# zhdY(h_-@Sqq*TQdLlv5+UY{a zL;X@$r&fPfj{4g7>sLSt=SZGmhv!r%`UQlK1n_A4{HjE*^WqroG>i)w5gL0c@wYLlhw>G^;CnqFM| zow_couu#xcbp{i^rKeu{z^64^nE7&_w@0Swb@|@(11Xu&J+%_p1z{nNdSf+TDKC9A zUx_7n0#Lu@`EQ3jU3i0+n3-6zl+MU4 z9p{m-5g>L}c7(G-HYyhGh-gZ*8$LQZvP@Fb)W<64D?(nd;&Omg4k}0cd^aYpMIp=H z&FxL@&XTE#!C)b~4Wz7GuPaeM8|@DiH2n5jDrUQ1HJLu8hkbN0iNPTuf*`}^M5g#ze<9ee=JrVPa%7zTeh+`O;ah z|8j9a?V7nCb6BFr#aqab6fJkKtGDg5cT+u0Zec;eNP`EayNe2sC?F5Po&72XCb_lV z@aVQVvXCYan(A!X9?S1|oPESciNDHQE%%K0CTwGEECcP8Y8(-k8G(YFJPw#S8)s6A ziY#4Jeg>hXGJ;wuAnT4Cc2C{gos()f?*obw88?WNtHuW4^M(c6@&1j(=3sIWo&yuu z4x)C3!CdP@k26s`IH=*0&|pf1Iu1t zhZ_f(1HX6o_R!wqB_Hy!vaoDVWkb!gm5`UlkN^+0x$-*p3iXbrqWb#8fsY(^&z?=>E8zpU2AZ>^fKK3p$NkY$;02j{7vgO#f{dhO$Mi5E zSj_-LjZBYAus)^KtP<#%nMGu0zhY!8duh1hmgU&H3Uol#*@jDl#Lg%s!Z?>WjLk~x zQ5GrAq)~_OZ=Tpd$qggmgK=!X;7XlAv|*f$Gdex^3joQO9G&7CkHiT*k-6IS5eYlTMAq9j+di zw~_(I0j)&xtf}4uqe~G0l+t)G-MoH@ia`(o#2oO_0G&8J@08d$1x)ZaBm7q1U1}yF zzekJr8HR;K|G6^c2Vr5>#bykgB%F@!Txkx#J?>7mD1w1O_N!X{oa^NB#Hc?)N43$i zim6V|$P6@1>O>qId4-?5$Nk>_=O5kQe6ChoorTkYC4P%d zmIO!%sMepv#k;!ZLJ|^G>2#VRO4EGmkJ#A@bmEJ-T^z2Kpq|(4>AqWiHwGc5jHxQe zBjo3WMyEg$9|9Gmg8baV(9$%1y_J=9JNqo#^cO{{6(};LE(=XpzB*Tg8Df19E!g-^ zfpl6;T~8&;9rNAsHL2PFh9ZN={Z(E*Fm`)1S)~0P#5|CC-Vo~WgLLQ?HO7_1Z=-$6 z!C7uO$)(+LK}5t51Oy4gon_9(8>8kE` zdttbDsl!x+m-r)b>?~Ird+6yK!1|K~iiXpvUf|(v!E;&PJN;0fEPNrGtL(Ip#t{{=fJ~rv6Pi;6(voebNx# zvrbIrYh~hSxa{XR2J83P;Q+~Cc*g;=*7l~Lyr#Im9tQ*t-g)lQ6^p^9I96=xEYv%v>lBCtTqCr3zxtUP|!UOlR|0x}Ei zTQTgJ>zUwj%eiuV-!8;GyMr#;lKUerf>iBiaV{qu2SQihQV8&}*+wUdX79&+Zr23R zW4-YUeRZ$F{ec`n;GodZZt&&-w9LAT69$*`xFj%~`1KKVHlSc)%4llx^!A2u#fOAY zKcgNdYPhT1VL(}VC#UHN`P~AtvxSqiGl62yY;Z^n*mIs@8PBe{x#e=zYq0j&td~T} zh`-v2W4ktqwY%^0Uvq=pxH8oA8q8UuaX7B$Db|IfQ@(aKss)?nCJ^P_PEOJHv}#rl z4$joJl2XDEWm5Up0F`UJI47p$Pz7Wp@#xZtS(Y>a0TJ} z$8%zJ0^~*nK;T4fFD6_zn~gs!{J+YojmKu9)@88ce=41Ixi*~{Z#H%cb$0;KD^HS0 ztJyt#ITDYAjqaJnh#)9e`h%T4qd=1aI_W0?{TH-JY&quHaQXU&HwqL$LQtzW#|J_d z2iHu+?$)dPI7UXc)sZLzFcq+fPEG@ZgOobeEQ<}!FMoMpH3LXH1;BmV_+Yk~?OiuL1bVp&wbko=r>=YlF zra8Zn0i|biC^>8|4j)(;FiNaS1xh77$88UCN=kaSS$3ATi+%q26`--QZD=R}C?P-7 zQ*Vr-MvrLAOWa}tNXf~{EQY;b_>`{tI^|OS~-Z%C_)Poz4ML=`pq-|77*?{jPeS?fj zztI=_?E364G)4OQet<)FG#)w^>w^RvMl9i}crkz!ou}H2=jHDF>MifPTuH1>@)~@} zRKED0JW9#1aMOo2mH4IckrCa|WbFIXJ@19;1F*7QdhzlC1?K}WS@^I;os|mZqk4Ki zffY5(Z9{l|;kYwarWK&`gtEF!1h@uT?ohe;0XbOj46KalQvI9x+IMb8ZJFIKpD&v2E;kGTcVr2=G@V<+Y(gLqaxtM$w+pF)M3l>o{|+Wb4Af zTVEBMrPKSbpxIc5fFb~4doT&nT1Z4hKGPkYtI|pC(8l&@3D6Kkuh-$Nzket9u8C&P zYlwN`vs*9e@AOpxN}rKgECq<&&IH{-JkDYoF9i_k^bE*zk`AlA$$()aiOml;UCga^ zRF4k$YZ6;YT7mc3R6FycHt4{G1nR3aARIuN1?MH9Td@6l8qtHpeBZjg;uqUrZ8{g^ zvcwmb%76&4alO;ww+;Fqh=gN=HC7FPxuFBYZ+&pBqft#kAzUV23FPxtK;*)3_|buS z5rDMcKE6et<$aDXCpKjI^M_+Hw@buam4(mu56xZGxx;FoK-Vsym@L+^Dv7qRNU$sG zI}Qu=4w0Y<2>N0#dtd|(xVNjg3|?FV2~qBIm1ZG30_!ZC%gtg<&1eVH8}!*7&-_JWt6(XxTHb<(p1`(XoagMndW_<+IxTIJe0M#$W} z;QT({A*Uap%W=k(Lw|uF8=YW%VYM24yZW=R@UZa|x@TASNYq#-8g*50rzwK6!)Mav z2iOaLUt?x&u4`=!%-w&R&K>L|8anLbQ&Rx1l6U(7KOMN~^;iGwLtl@xBnt-Cy4&#| zNE8&5G;NMhpat8yxOC5{H^=^MZR2P)=l$IaAOI7kQGQXM=+$S^wYa$G@Ln#fv^27+ zs>*);Keqdu)wK8n#^-U#(nBRq`?RKph=^TND31I2@CNudL~lrIHSBU_7Z#g7NaV|^!*zx<2olwf%YO`M z(1;>>cL>|t#k))>QR9#G`S{iEyX1Dx{U(R4=2^?jp+*@|IHwFT1ZUMcO-GJZ9?EY6vL|uF#>1Ycqv5AQZ*ob&W3M5F=g@pwOQn`PcHKJtf zSr-SUr{h7l)TY`OnAVvYEO0&;`m9L$@7D!h5qF(9Dj6y1&(hMgt&do^xLdR5Rr^3w zw7c28aPcPi&zV8UgQ8WSp<#{UW^db0U*F*DzZ)8^`b1+$PtQ-g9ug^l4V295L)Frb z@}D>8TI$InmH}v$Tv;PCP!P6Gzgu)P2WAGNqQ%Gxi)^@HxlvPckhTauPYEAoZ!eWl zp5hJoMFG!;0))cRJ^k~6xrf81V|kED)Jh#46aD~+th=aU-(1_8fNDkA((Y=rF>!?UD%9Eg^|xtaMd}wX`~YO4U}1^< zwY75n8d|=@5NVKYJ_7i1v z)YVC1HA~3d8ZP1eHtv%B<^{N0yV-KTKi_0G>C{DFyI1dz4^$|>C)S)0^4o1~ah_rG zJCjyfDE9wa2Ft-FM>1Dq3(4_l4AS=)23WaF^u^~{_qv-u1W&_)x?9=fHI^}7!r z0Ad@#qYIjPGyYo)2);Sr`s(aS3Q^G!A$B9{v7DH?g#jd=RAJXsVoDjNRx+s z`l4~acicqs{!rApzNCGo=Tn8(NeF9JPS)w9KO^T)e7%}EFKHwCf?e23* zG4180vtp5KM7fc;S)XFDJZyFxMtZs)5bRG{4;VpP2ZSURH!8Qr4|cw%vtwIw?>iD@ ze% z$^70tJwMpUpg$)uxd0=l0n38f>2z96d!qBsYUa-)%WYQ4W>u>8CVs-i1dDenFCjgl z&h6FVM}W41!^77eq1XW=g7F|2%IZ}ptEe!#Pe6Izd+=w41h}t$HIejbkvPyFOlKU% z#>Q+Hn}4J=KdCGDh>ODk{Rf(eSRh*r^}*?!!vpzVMAMT8y`MWbbZ3bF%5Jea)Yq42 z|G?_(+zUmZaao)Lqq}{aX0f_V$6I0z`eJ*u3JpSUxIH*5Bm-=ie5$Fnzb$zykTf}u%8%eR%3WE1H3!TxlP8<=gGpMIiy#++=~SqN zW{*g0e(A1}5eOSX9biu~)BaFuU}R%Mx-uc~Jh`C+N(pFC!UTofq`iO);@<8W3mEhU zm&5#T{vUa770}h%^^0OFqO^zzh)9SC(hZ6T64FR_NlQ0afOL0>v`BX&-QC^Y-JCJk zTJN{sz4tjc`}SO-@Q*p4`HV4s_2A>zd9mI8NF@=~989IfDgdU?@>1(KMO{mJ$*t}M zW|qC#{Ib;p_XE|>!dQ2f2R!d&jJ#lGP7)aK3AYjo7Sb+3JL{TB-bBBdVA5f168z}$ zVDSP9J3IU7_Kt4^$JIB49v}8kZd~22*&X^Oz^A5eEDGek-at~K&IJ&L;F-knm+0zE zdGFF8aJR0tRXXs}x#NTt!kM=$x?G$9_dg zso3#=h~L!}z#=b@gTzZy!2_WU>>3!?VS*Gmpdkbnnhdz}`SB|(L8#Z1*&X>ajib#k zgz@C*=^3l4AMe~=YXJ#f>zF;dT*b$`bsp>=3PP=Ez}?Dqf@B)>i%Egh>++8TO;c37 zoQ33YbC3u;)fVEwB*J|4G#LxjFW&{kVk8Ul*x4LFYy&$wAn%WOaZ^)yUQmX)&oq2I zT--vV16-&(QePTp3EGMxsY8T|4kEwe@w` zYV8&k;q+WR^lAMjKlS3T!*3-G-6M*dUfv_1j^MzCCk;C9Dp-iDmh+skj>A-ATMa(3 zKi_-n_hu>*e2P)#n99-SaJ08Q#ivpMaJrV6GKZ%)1XX`Uf4{P(aopz=%neR)P> zUs4&INS;2uMMy~K1LRMr&)P?-shclnKqRuVxFr(E(bYxAdXF~g=&qBz@p4l-hzg-9 zhys!Y5GDSx5!>F$Lk}E(Cz(r9hLMVV)dnkm*VfE?)jnYr&4Wb}g z+5PkmXFw;Q;-m;-ls7jw2c!m4WZRAj9q+C-4|``^y?Xr&uhNB0s`4V@jkzucHg?UI z#AgiY3*l}Q`3CQG&zkE*BDe2=@?d(CZV#{DThUssQtufAC1~^o9Kp5A{bQ+EGK1F< z-E>YE8O}X7Vlk2iOy-qBfrtUPMN|8SsXB`%AhSscsKb*O8)h1+)MPlZHcTqpao(6? z!EOM}lm_T?{*1n90q(VVc}+Mu>@_A;koo-bUgRe`sxIKEm&VE&gy%bcro6^XY7Y|z zz5%6@T8`Jj;qs2|J54BJu;qm5cvA#Z1wcC##$*40;5}`eWlPej-J%#47BVY^@v9kY94((_yJ^}8>Ns(oqi>p?ySrQr%}TY^)(`*}t^k_?%g z&!2mrRi3}<$cTmn5VFV9m33-vXp64_-l6AONKTLFDHDt@jB%p+cDNbzbdB$u(3`h^ z5wNO>&`Vss=JtV~h{y8aM_wQ{M0T*L#3Iuz>`jL{)2ur0BELAl#DF4xr8oYoPP_6q z$l(#jYn_jzZBW+=I=cBKs^Xcs$IP(z2-@lhb|_N|K8G3Q=KPv_YepJx89EN?wRUZx z>~`(j0D?AK5sCs^%#ThiLSkPka-dpl^%VpXc2+Av#-*{&D5x$00X`SrPb{dWz5}G` z%I@HBM!6=vU1>%}DDj3r&cmx2S-l@=6kTf?d~!DiCd`#im`bf?T0kx!3||-GK}eXG z?x^xypv7K_rFLq7+6tXhTZO7~!&r?}E?42+?0abQ);l=mIIYPILTMA=2^rcSQCT|> zto)_+6v{t}IRk?$I{r(zhXl+^p8f&M)$Dl&HESFflPe1yKoARxh$KjSwF3;b3H$3e3^uAIxrbcJ z%0xW2sGy0K)$30nvDEMD5_kc>~ zNe3q%@Wn1F023)X@)C?F!@ zxLNnn%7kh1zEP@5KE28~w*%J^1q=wY-pjU9Ak-Ng8tyC=2sJ!0%G^ImP?gETDO%FG zpl4u8bR6j~G8~Fi`A&fi7;SHc%jpWF*|5H?hzsO_HqBy|9cN^Fj@I(Q8$c4Hv6-(a|X z+q@#2)u%Iy+iybHmlAfcflX_lIKW-BZh1R%p1Z2OGcd8!0Z7 zWWea1(`CuglkAWte>mLPiIFII1ncE4gH(^m3Xoo|^A~`afh@#3&!C4Mh8U65#Py5v z;RO*Pp>_)eg4v1*^0QxG5?}f}IhK{PxeCvzJWYs!u0h)lHUCmsDpdK8%d1D!ZUr>T z`96}OWP>H@Op*QRjr;A9zP_>SqNP!IZ8B6ir;+?Q*#$pmbYsobLeu6aOzaDOCMUZU z6x>5~c|7t|H5S<4xwL^|kghVN$;R>T{ZcA5^?Wv9yxHV=)SKDe)dxMpj@Cd+ui&qN z$cmYzwVLu1^i<{ClKBNYTYVK6G+zB7fu)X!ePCc z7p+0#O*qKG4C=^KV!=c@@AEA>{m$^P!r}*m8M)sn^RbetKY)B0wVw=GKRC%L$v618 zy<3dtEDK6v(kF~w3QT=sk6|2oeNi`I?`CDhYPw7vqr#o{Vfa1@mm`;k27_uCAb2Y8 zbM0i#i!VM&>9%(zNH{`~f=mSxdMb}fpYwN;W;r@j-K!a5uGg=_7L_=%LHwhF0sJf8 z-rh`GTesxNt5rjKthKF7%Q6Ox#BOepfVWL!`mjq*QPKP$_C_)LK8&wQ6?R|u$Bw%z z6jhN56hc^PI#%HM*yXl=NQj49Xkk(rJhyit-Xp zsQJPQe=SV-$HUiNDzLXbQPU0F+apPHQq(!)UXsP7zQ!(ygM;H$9(~-d48;W&qfS>& z-)QLt1`3Cw;+HR9cXp!yB7cL6-{i~FF;$<=0mTOyBSoXR&{K$&Z~BD=z10^&Eu-gs zo9HoXYr|^n8=IpaeS2@E&o;f(lhy!Ap@2{HWOd>*7?Y<~7JR|W@IxhU1V#$LhA^-m z)CVSa&_A3g`a?15yjY)4z>+GJd%cfJwzg-)4Vbx}6sK~L4*9|H7@*uBlFqv@Q?*2^ zZAIwq+b5iyUvz5lTg>{~TA?rkAuYW{@&;>Ww+qZ8g8)6qe0tRDaXKQ{q zcndU{{0$w$ALex%>KfuQGl^Uk!EQX9r!Shh9?c|n)-}-6Vp>s*!Hd%SOTt^1NF|ER z+JFAtgGNuNM@Jmoc)2dhXP=n?np*Cr^D`jS`*@a!;gu^YZ0JSmH25x z2XubT^jw}s@Cw+;|C zH^cCE1k+_(Wkg>Z5Qk!5eQ@&IYyDeRnV$(Tq&a%Sa zKaVimJAO{1Tj*S>1VjX(3e1`KD9LWF3#{?W_vS0GD}1G z40WEkpr~=Lt$p&YG839j2yQ}0Oj$b}j-s)pZ%6u{4NLvGj zjt>AC%=;(85TFCS&g!W7Cm?K_gRG_(h%0-Zpj5=}UNcZG*xWnldBI4ZFqZuMXtn3J zn}YEg9Mp=7`P)iL)|)+E#n8%t8;#$sgL_T}YV$mffbm>!492u04k2O9R-mq2e!mN> zYwG9Ji=!3FC3>8;>aE{NsO7U=0R&gq)NDJLa7eY)h)XU+herR>-UmN`GO~2OJ_9O> zF-0|$NSUMJJ1YSCY-@YU`0b2jIhq*+*61vPqHFKGt#A z1LoX{vngE*OG{9myUn|60S?Id(VkC9=^;pX4aZBcRh^xI-AR@E6HZ6!`eC^xBPI%h zPA?At4_0J|3qFFNkJRn0dQ#o#Vh1I#Xt_+Cp>|*9^N)%;9e!GYn7$ze!WyfwH05N0 z?KTkb@p2M36qroDND1zhHEw>t=@ZLYlg@$YBI*Gyq>QPHr*i=)e(PM~{FK2X)>*Xh zxzVz;WU^>m@^`*hNSh=uJl#IqhG1Qx=7!tB8?sa6otDHxYN!e*<;(?a!bf zX%F1zylWACpnoE)0(tAYGJAKCx}#_eccd65LazbXKOWq{ZRC<~IWH!FJFRW0|Gr^x z5MX$W>2h(kKt2TpAt2+Tjqnu-4D2j7ww*oR{!L$PU3AAON1on&0Bn{MNQ`MgZU4yLU%Rt$jhL&UyP0 zJNp9`7M9huIYIpYc@>N!GFbyGHda=Y$@zJH5fPD?t7=+W-^$C&lZ^j|C%L(G5xU}% zM)>gIlmBA0B&r&#|F4pj|0Q%tP}Z`(r=0#nRmplyW52M|Ry1_DOxK-(x$((i9KMbN zb)Hdr>kh|tz)#E30~tsf%4k9OZ3ocV1$>`L9d$ zM7F05vXoNgvNdAJzQ5!OXoMiQs;Vl^wEwcx&WgfyhZVcHqU%snGeXbI+@8B+FtoL` zHv_%R#wq8&FGw8Wt3S02^Z3q!f2F&-ciV>ezxQwa8S%S-#-5hQGZJupIuoe=ly_mIRnY${cOBd!u#UJ&*vVLPX>p ztM!zblN%X5CQKU>B*@DhQL(YP+zh6IB?%v`Rv@K{%pGP?2-|E0ks{mS2=Q<0q3U@f zj;9++A<%`zB_<}=&bUUgIr|42u;kcJyn^N4GUmKKLyYkjP!Ok!oAMr>zPjr_wJkPS zzGal(Fg+OmdV2S%7#FxK{*X4z9TycH+FVB?sEIB;7=I;=yz9jPZ26#lkadED?ax+# zj5>vJbHGQsR@K>s{n38+2@qGwN$rO&(e~&cR4AU1&ucPHEBCZ-pllDjz8e92^v`fJ z&U+WBF9%SdSAj&6Krrkj7V>W&6Ml<62*=!1;WYi8IE7{U}rH%6QoWn%+!V z4&`va&KK{~#~K0E71w{7D&8J$8t_GEZ9I zuseMMybu{Xc?kdB%GMEhkro$GFo@zb08bu0THpq0GSEoh^kt~ZcMe{bl>=ABY&g#U z@hv6j1Cz9F2_%W0KS-4H&rpIlF<>b%o9>mju&`VlFD6xHFKEdVEo=~zFED!!ln&A| z)^2GsI`)kA?&MI2#nMf!Yz*ZZk%9msV(oTl`eP=t;LOTX&IZ_u4zJqVE20#vjd}vb zGU2?V?bG0nE`?A9_?>Q+kp*goh9c1Zb@j9is!37>FI71Bo;qS(e0mAojX%Y$pSJ=l z8UXX$(&QH}^5cRL2{?i>nQ{~;T&W;=go(P+Qu#<5uzN(f6*I=MbJVC#nID8S?4++> zKgOCc{7}vt_e0D#!}eh+a?@m*uzbKik~qSwfBFOzZP1TIUjkxCR11U@WP}$crb#U1 z2mjFmBqk-%8OkP|+bfA9zd=fBQNh>_l&!1!zjz@j?hwR9aMZ>HZT7D2h=%$O5_|=k-*-B_-PKi zND;^uta;FePOi-eKsN#1Rgzdo2J{xKcA?L0z_8yNcN5qO z(ZF35(jM!boh=bUS4IuvZ?yg07gn_xFmhxJtxH#9e)@jXGT_5t80|ld&X%UDR4T7+ zTk(M5Ad-Ii#sf!ZcN6l_#P5D;FTYNI=4O%#Nj~5snr_U-{^rWn@KTjHG`|JNZ=02g zbYykCP7NR%G+j%Y-&5b znT${~My$=3TiM{MPjJC+k~M)<6Iy>+JnPksp85-_@U_ zvKEn$lmrDrL|^~G8`KLH{j-S~8Gi}*$E1;i9MT3+@lqX2P<~%|xv{Q12-Z7TjgL5a z>w*miBoO9dSb+$b?Z2*#@bkr)xDT7h@(eN9qrZl&<_v37`e`YFoMBcoKrU7-|bO|aPiJQ*9C?N! z5ArF!CY96qXKIxS{@jamNM$GJMMqG3Itvs~TGk^dd8KY)`)P^&v&!{x574&{n2hVJ z@yphG-LD>=^Zf4ZsXLbR93@5CA7GgJpT!J=dB$u|H%MVNPi2_z%yQyV>ZgteGYLZC z1_cE@>~KC==en0E_wHhxYp}=;U)6bw4d|w!`pq1I!u22GFVpV5%XLJja6Y?6V-O+% z`dOV0RbEL+EEItEG`mK_4OI%iY`1I66+3HMT1ipT-;k|M!<3+&r z@RKglvP3g{Vmi%bEimOYsR~?NZ?q(M{T=-kB%yvh*wk8TwuGs8POjqM#}pbN*Cj~z zvS1OtgqYJeKGzgGtuVtA(!Og066)q4v+gpQIqAzXS~$NNDIqvQoH8iLg8F>oJb-L- zYtOzWR`j$I2BoN$AdNQHHUJMKdP41F;DZA;jL&^#Jp9zB-0_T3Cg+8 z0iF95!-7dj_!TB*$W*Iwl@SJ_(yqj5FbzZpiA#YY)-)}jRh=nF=={CYLbKHOBqNxN z!zfJMPbD4&@}cM?C0U%VN?B^scov@l3)-8i97vxSO+R))UQH?ui2~Zi7n$)5_?^iV z5VeY|Z!bbNIDH`Vr{o*`W;uBU4I^zqFqoTC$$vRPA7wnr%L@X(|A+O>v;78j1!e=@ zyT}I#s~5y9z=0(m$%SG*3l}?$ zbO>?MQ-24vxZO|sRVc2D`{Lx}WxY&oJ=a`-y^jF=hL#&&gm6>!{1oLC#2d%&0|{pmNe_T`mH zRRLe1(nudL9e(58xAMAA{|SazP!1&y#rEZ203iwbYFHUjKkvSwlp1!IBoo$pAZx>S zwVy4IF30mqRek+~+vW_bYlciCA|hVdd09UkigOUX(SeH6f%!;1#`J6PbRjZwN4gLg zw&@L9@mf`Lz5t|0-C|-b-?Hsu{@vG8%vuv~@+7lTYo}xmtW)7lJZ(?t=rr9FaPeI# z{y678KvVU4B>jdT3m@OTqJjw3WjgWfddg!`hF;Qf9;u4(?|_oS%SJ&DYYysAG;Q#A zgR9ALj=}F}gO#2h^~%>>2DM)8cr%%f5zyHFfpH$Led3B+xlpR~hvNiPm&>n7U&9WM zjgJrD(U2nT)Z=a0&ymLaIh_krAbQj-p~3uA9(_k$y#v5RScc%U(=DNeC>HoC76z!e zGzmlz51@5dx4ZWV&?kwmF8LVRRa@4=_0t-EO$x>lNl7Ut>pKk3Hr8f@K_|pG0thVx z?Dz>r?#}JpxPBd2pl~$p9M7q_gYnwhu+7bzl2SeN(U~VyU;%&$opFK3as9IW{+W>4 z$1ELGbxAWbpFLzuD2BctG139`bzfLm7_?{~X`~(iE9;#xvIN|jv2RQ#v@b6m@6aA} zf7gOt1Cnx}dNyqhUvP3FdMqIO%)Erh>KjYO0+b(Swv3K?0mt%bw2l<@`7r2X0B*evE>|0rVvWuAmY~|J+ zN72ZTwiLOzhgJmzz(YnQFZ@<0eoP@6n`j?;7wUTAh%VTRA;$p>CqXocDpwkLUPecQ zw6r~s+lAMEO-j_z=r=-xK}LK_PHWnna4_|s?rWI`jUAyKCVG1Qddx?bnkn#_f=x|P z+}7v$ZjY#Ngu1)IVBlfv8Nx6Z>E4?r~lvY2TfJ6Q_g6H2`7_SnUC_1tTFuKDsZ)0&4i{YV_>44h78vgTdwfUx(Mkbo{c zAk9u?Z=+EHU2MYw-2{}KNuWWLCps|US(O0++!L}yF;>V726U1Z_Pb*V0tS3hdcw34>>KDM#3(^%oPVdUMs}%78)v=;f0RF!>=@0rot!ADb!2zxW+2@&R0Qv)HPpmKsXAV*{GI_w`z!gN5i zf4LJ`c)2r4#ymr*vA~g`m{@xA7$17#k&-iq^hz&fI;NoRzx_Fh0q8!Z%5O?iSG207 zx`!7a5!*R%?|~bA?-MM5HsXm9Dy7DlK4H8p^_}^gvUK2845Lqs8nmBefjNp-K=DSy zbX^vKmZy(ZKd}Gr<+1ZE{ z=xLz;)#c=5QC)Zl!K3wcVG0wN#wn!&M(^c;7CN8NW+SAH{Op#@0#wG6)gyD*)o`0R zcKYx%8G>%#y`woZZv<=&s0sPomSynif6$^p1*~5(Rn3CTkOJiLmwT z^eQ`I7l1^r-kYfYmu2L4clS8;uXel3_W5*kOg4go*yBJJ zL^7Zs)2Lg%1M5sfPyJQs-B^i2t2Q+4kwPPHAbbMJFTO$W@s*SPZccwl0SP)m9|Oc1 z>C}iTBNHm(&PO&_L!Y)KgmOm?^#umff>#M13#6erJhTB7scySh>QaBQy)qDo5W8jI zus0^XySsazM~Brnw#0nVmlSzt6Wx~3SYM~~xi_^Zg8m_%B>2f*Y5bQH*8S%L)zNSR^VkiU->y!ex+_rlTc*ofyWVVs6fC9p&BvpFDh z0QWVt##VG}h}YgQhKx6dv$_S_{X9dGZN<-ouFxj*YMtb27-+uRAX2^%!~Dl@F@6K~ zjX*u+$6^*A2Cf^dtPTWWPrsG~2pm=j0(<(0WBJrQ-O|YZKr#+&Aa~ma#Q{I6)ri%O+T!P&{lN4E(7{u=~vzDy3F@4u)l-=F_G+Xmsod;SxD z+csuBB$PP$GYvi6Jt_EynWhQ-IuP52uS-F2v7l4K-zPsl6LKs^8$sLK>kPH>wz)C8 zgQQ4mlKl5a`S$-`n?>`7f6s}$>FxVia2Cp`)WKi0Kgc#TH1w+>4`-X>_vN;=Eh*_= zji-MdnrV|sMv;sWCkft+zm~nAA<(IbJ@&!3aE zJz$z#brJrpQt8fh;yl+IZhi`!DD-HM8$Wm8EB@&8g(LHLh116GbMUv)18`Wf`=C*b zVd1i$U)UkP!Q@@Ah_4pace1~#0#VVpWK!gYh7Y=NN7)NVR zu|=Y{gaHQUMuD6SizTKPl{JmBJvwcg0~KE*226%Gu!yg(o!)(vSzNUK=m{eZxX_`~ z&MqvB*KjcldM+g-m82gmM46v=^UfVoMryAc#=elXf}&PE=>IS1oo&wpAjgxxLGK4F zr{B1_N-AW^ZJ)y|fldC7A0{Hu3^jLu0cm$<;%g3o9&HvD(4nRRCN`L*FPJgD14JSS z!RkJ7bLq)d(g+GRjh-!VBSP`WmI!KJ4u}CoI8mM#2-+$X@=>dqMOCuMk1$=45wuZ$ zfdAR;5;srV=6^lHRKj8T93e(Nq3cz*|YV7FtLa+KhC`eO>tWSIK0V{5q zw|s_jczhE3(f$-CQS!4(F&l^^dq7VH1@(#=w60gLUZpb_J^+yW!?Jvl1uj79Fjq4+ z+?SG)lFzmxg(^=S!@*E<-XJ!5WZUjg?@K7S_MoqOO)B+qIJYi0==T}4UaGcPfpiLT z99!cl#9+uyFi3_SuK$o}oDM*<>A977P$q$20V%#QW9;hs8Ue`*c7c&n$RGxHKgaNo zVVm`IB}lNVLH#;dVv0MjqchaM16^UNYUW*_MX3hppf^4N=aJLd87-JqcIpPC5EUW_ zhJxcp!JrEcg2<{Lj~;sBYA;qf0l0prs*j*Bj86Ae$@Yj+znV_O7RU6UyVUAndF*1C z+yf`cP&(4<2E#N{m{gFS*a9p;R(`e)AhpuzN?U}dz0>bb%`Zqstk%eYfat=mNP1Py zj<^Ao@e2jS4(*!r&>Hp^hutA32OGT9uJ06!9oDY153@7=RtRR zIGp;{VjW06YZG<&0=MW%*AJ)i!O4<4utD2dtikz*Dz5ZGiS%Qk8~=I*Yy%!e9IpAw z$;)parKkhYjU=>fe*a$h>p&U&;IMk@0A0&^!9oEe38{1Jq^F6K+A;csWGZj*>0LT0 z@KnG_I$cTH40(CIsu?jV;|V%Fo%=iJNJ1;x`h*)88L{zky+N@7b_6*EIZzqmp#%i> zy}o!eexJFB$D2Df%{Dzbc58#~8Fc-B{S!eiDKBo*1cfMr_OKzCH{8O*pQ4}>bLjw- zC337`n4wt;+6@GNe)Q<3z5R&A2{_LFHFeA!YdaHiwL`)kCLS?jJX+dsi>ds^&Xl$@ zCX0=9&Opc1rzVh+5qd7L^ONwMcPbG(hku4_jL(1mfX&{QZ71{vXiIlEIAJjbhJ?7T z?2n!9o()8-J$?EV<_-b?wPkz+eay*IY0I$QAdyn2`I#gBVMv zO}RGatWpo_4zN*vQBkeG$aYwrx9D>hp6m3K%t1;;MJdaxvU`BkL2I21whxhqED?>3 zl+)PL3_;HT`H9Y0Va(*>EJutMcb5CESAnMD3ooyH_97k}B^kce!V44WQfJ1y61#wa zz;G@TOvoylZfH`?1}z|fXH%j0%PZ|5HZtAXW-{|F1R%rJJDYuZL6=F!m&99GSI-v4?{*;16iD{w6q@GGS6PSmv~v z#yN35`+74Nv@I2$UcpgA`5;0zH9#00}Jv6CJ{CLd#AiL)id0w_LW) z6_8+G?R`xRdiUxv`vW=ccpS;B;zTM@w7DxES%5sJdPI% zbe?cx&Yi5R7tU!jZEdZf&e4QiUO*R2h>Ey=su1ptRluXRQ?-WacFq|v#T$tYL&|Wz z9>mUEZV-zWETo0n2oltLt>Nt~Qc1e3%>Q~%EJm>5kQ*5Ny74r@u!I;k092h>Abtd1 zTJDz+=`JT12In`30!a}Db18KBa3OT5!pOha9Y;u}l6MwU z#1n5?a}3*^nqFK?@zw@)NT_nT)z^est>c3wcga+>!5`;n_)J8gszZe2upcjWXA_E& zJ-qZ`G>aQ{uNVr!pZ|-g1%hJet!6P`)uLBHrp{C4UkN%ot#vH}jAt3DI;Y=X`#%Oc zB?62c;y}|Z?aG%_gdKv4`yY;mTMGp~GCY?|Eo^~qzR{D@iOL8T%KWE0Zt&wF}!<7&3WTuG=;WgBFFn9AoWF(}V}9?=BD-{RzcJVfx+IhrbaefOaO$*XNe=Winuc1nV6g*9 zpa0C!KR%qEF+!{(M6X&Y7DGZtcJ0cQD{WRVdy|VDe@W*!*gSN%&saXqlQEBA(fh_5 z*O=7-is`PJ8&ZwqG(DPNZV6m^%K_)I!##2HT(JU!Dx2vsHFR&iDGFq=*|$i>X~Cuh zQ5i%a1qCuXr=0n+H04GsT#lh4yQe@^=rp13NiTwz@_+ZV>`r|6e7v{(@vPsdZKd>L z6d@Jyr`^50K+I`BT%gx9e#H&N(#-xM)44O(fC9vE?%%~}U5&K0n;>Olu*{eYI5nf_?%WS`^1V~OF^g!z0aK>@J8raX?{+(CaCC!SR5KPz3m zRoC{;e*$D&Um$yo!AZXA%*{B{vK72go40TNkUF_Y`rP+R#T!wBtaV|J#@c>71Irb& zaoCj5RnndRXaVF*RC%I=Sbs|Y8c70w3&a+JUqkFa1d}kg?XJALkIx_AdOa7~wSbjJ zbgbYv)-5(xMwpVb3?S)__Bvx>aL+NWbB>3NGk2mX*cZyGE=XI^e8ZB0P*z(Y_kZi zIjo=n=LAo!ts%#Xx%y)v#6PHRPfuqGizDhop(z9n3&O@D`UfyRGa7XJ!Gx3u(QJtE z#z&`lsAVLmd6yWDUzdXT#m~T3NhP&uOc(N)9mJl<*Z+WXi@>0hWbRV8qFC;P&2<9} z{4yRM?R{IILg_Y`{8+L_*a`~e6i8SE5ksY-WQlasMZWbwmjs~^ikow3ws)z&-(=l0 zxu-bK>O4!ODBu6+yvO~!sFb6w{N+;|Yf2nz^ev17t$3|Uo_htk!r_P2(W`pwP;vB;mvcE>AH7*CXUT=4s7w|_}w{C&X$9#UGTpBU4s zx|5TbdFzAGR?7Bflbdwxd3x?b+5t29m{sHL_hj*O;ebjo`*W*LVW#v60;V}Vv(j)- zUJ^Jl=B@GO_R9LrD-K6>3rZzsg!$~Zy!mVPB8RTIV`Tx?Wm;pW1RHVH4}qvkoABOV z>0dJGUM|JO#&wL~5JI)FQv<3HWQdT+ThH@ITs8!pW(Z9@K8iK%xFa~!eaiX+9p=3s zvKO&oY$lkHc^$r=o0s=PbL2geU$k+2XL`{+kkO=Dys&&!m$>JLu@M$_Uu#5R*i(iD z9ehd;PqCNU#~r_~=ThX}fPE6q{S!!}?{y;o;@;r@x-PrHl`yUGy%5B3E{4D#$Bf;fGs>FDEK-9-)ccbTP%!HN3$%g%XCUq)?WH0-4`Zw*cN(6(OgDspRdGI|9)P6j9uFQkS zub{ww-PJWbMs?4#W5|9IBQ$dy1UIp{507@d^;*No`5cMvgm2xOi2FW7it4m0@=bk-ff4rpr^OFzmfUFuq*b6eIW%e@ z{`SpfDXAO^bv_LXsN-qQQw1fosOT_(kZRxW z`V6j-sdXFK=f}l zB$>v|OPz3Hhzmma-)!U-8i@qz5fNUbDz_#m6|LM(TFR?;q0c)4LlxguY+CLPYXi z5OE{{7iV`O4_n){tylKkkhQ9zqvI->>aeaPR6V?0cP5bdxxi%q^x`}(Z`UwcI3hek z!_Y8c#k}HeuE_(xfT-$CI_##6^?GY%4vB=#mAx^(aa$7stV;lHPfh>6(+Iki9AF7R z#?acPXpQpG$_(U4tKLLAGxUk9R@)XK!YPsPn(D?GEO~npSY5gQB`k`fwu5wiL=3Y-F_6&Gca^uer zAVldQqFBaoT z8l@mMKbv3UObf)A?i(5&o>Vhw_{2S73jH^cjqR0L8zV0el-5VNz;5v6xq<95S&5Z} z#Us$Ies4@M^c^ZQeZ5b_+8S<-m!h)gNiC4~t4}Z`Mrnb7<#z+H;#+&SJI^-gB*Og; z6G+*?M-K5OjU~9A^7)3J$Gr(OHA#!d-O)q9A=SKq3#n9hWHZMa^Ug+HT5z69`(2J9 zy8qxoXNfH~L|x@Zjjzuxeg|!jL{~W8FSymNp@oI(8*bbc$YL?lc30$`I6d7f zC@r0{f1N2=_7skA)oAcS=|0;`=@@o+1Iy`U5Y)mb4z?3xU4o z&pq+)5Um@7ZN}d}j?n)e5vCvzJ?q9t79Eb0XJW%>5Y(_;SzEkqVb#22WCERM6k#s$ z7F_1ftt`6UUf%T29YDj)m%N{j$iuF2ZZoJax8n5+pW2rXy!r_WQZ#Qg$aJ`|gGWoj z#1zbXjcX{+=mtCwk09G>F>G+)f#puwzL5e3U_i7p@){FbX(z-_7gcr9QR*@4c)0!#$>__6NqzU8Q+!2^H;4sKmIZW|GaX7a7< zQ2sFy=P8zCnr?6rW;NW8r^FFDA9eoD zmmg7jP+nxYK;(id)U8i)-lC)&r8i!LX^!`tl#ca)2I>SjQ;{&+6c3@w7nLZ5WrIFcBT)8#REdY?}B!pIRp=5cDDtDniq+1l91! zGTrJG7M#@2u8`zrrvJ1K{*wDg$GR z;)zQpLY`5@%4#)v^dZ&JZJe80HQ#4)(odFsV7s{J||qc-&|*1Ski?Q&ScAEfj1S(v%1IkUMB&`e*2HdI$9hIeCuW zh$zwD#N!F`^W3sQE?8U`fay39F@wI^9sHUgC|B#xyM<%3?F7w^6aF#7v-jzmvx9<9 zD9C2!=bOEnW`Kg!{u2-kF*|XR;hReh+Ir#w7sSD-LUohGCk!7NZ*phK`M{w8ZPLDf zNb48RxO8eUN-Xs|;28|dRl5Zs2pmgCrzL(N;+`FAW|5Wb7xVTln!P({}i zmI^3-^*v;_`+}P`5Ev5Ula`BJUSb`0svE0?dUfx#fI;~EZ=H^cD^Q?sLQJk) zWcBh)+bDl9o^b%^Rp0d|Mun?HvV0kDh&vgItOL-q(EDo)c3jCFh4Ax;ha5)(Bfs`< z!(I;=7mwbjkJN6dwqf@pC;cY~fn5NKx5f=^c7TZhpUa8LU7raw?I3kkwSEXG!pn}zo&YvVBsuONJ2a)0hH!!fRy04z*S1&h;k+8~>3@S6tL6r}29U<9hnnbM zG1Tsa{vIwa@Gf)Cqz&qZhJ49Z>8f_xxtAsmwAzl_TB8cT&(3P0-rLlIgh@NLkE;E6 zv9hxpJ9zl?8r0gjlvjL+oZ zB@w@!!k;M+-{lvU0$KiWz>`i-v7-T%>@UB8J}j{^lZD7nniOK@tF?OdXY`DjX^+q6 z9zP~sIy&=R9V{b^78Jd ziOjtR6S(!pcwOCGoqNc?40r8_f0{qv`uZ>#@y2+$u!hF;?R_V<`T6;xqN2s-d;cNA z!Q`rvVUAT?S}OeVzwAU@foV(s9Zz`nw4;M(=;k|h>PEcYH=UVHl!&P@b}dAq(D=iiH09GLb< z0@AjqWai0Us)o9_)Dc8hgdplAD3F`|zNG*2id(8XugEgStv6pduk-NzREQps`8PfA zQ1(de3m;N!xOQJ@YgD^S>28;>|NAxPBZuug95+Q6T1i$J&_I6vntnRYo*?}7}E5>_63j_(&m=#SsdE)Z8;$#g>yC$Z*` zKi$m%QqE`51ATUxcLDnC^XP>}+U(av>jwszcxw|-L95@}q1tXrxK4_!#ezR=!% zCMVR$g0;@G=zxf0p*dI*Hau`-mmAl~UF1Ch|8&Jp)=tR7=vW?KxB*e-@MvOOTr$5$ zqrI}PY^&_;{Co-BQ4VdV|wu zfDS<)&}mIAs`-8`=em1$zo>*2x?7dByC8Q3yw)3>?*4v$-{a%2F#VE=0}R7tnyiA( zU!rT5fbrX4kxh^g8O$w6(Rm)}^i{y=z}n6Mss!jgQl#S^t|_oIz*%^~?zf<|a*2#w zF(6)nt#4a9xY+@KkC2fW0CK_41dR(B8{0Kq3W4E5y;u3aRK=m5frjx?mQxQiIF|9b zwK@$3mK+E2b$Otj*d>#?_VUNAiE3&nENb2e2Qk?90YUR1kwilbBQaKJ7 zR$g*U8;w^dUgbhfIuv+wWcpGSnZY^)1>b28tUE@XuAseo^#}C+m$5ZxSdN%)k(_G- zL#Ef2VGuF`!fE+Jvn>K19T?%$IjrF9I6*j7e$hMhf`)T*Tc;*uqR4jT?vihhsikSx zneV-6<=eM?)#{e_zPSjz&^tCn#igzSd|HAk*LY^adDE{8JLyH1Gci^01$B=V3<6FM z*gg86M|79G!G`-^<@?&XDNhvyufQ$5OKL!W$PE+(9WoA+`*uIPR10RPf+l2eq-my=bca*h7?s3<;pEvsDFkdTQ`vN zZ`#{K@B`~FvpM_VH( zv1glZwMNt3QY0V%oE-sj&YU0A14>4zSTbA6cD^eJ>l_p7Uj4AQV)zSf@*Z-ZSHSvI zSbDOU{RV{LVIi$@@%4*Ok3d-W1!hjjXkg`HL$CNHzZP!R-TA|o)bbc(cXixlS%zxm z9CUt7W|aWEXp)Q6!E6ObfJp)n0)Uk9c*$2GZC)*D#;>kkRYODWpU>GJ^9N(& z%?z9xey++v9{81TKDY! z@eVrl^SYx6ONQeGfud`QSF+TaP`i725d7w zDZUD7@&oh(#&DDZW@h>I6^LKj?7~Lma`CQbUV$?_j`dO84WO{t96%I1tPx-vt{08s z2apXmo;;N!TK&ihLpGDIyKdan8_Ir*BkgPQ1^UnL-Mg1()JNSSnkt#v>)E-%V-Y0q zhr~jE;5h#>9|{5Ug)2N7Jrq7&Vq#RqAu;Xn=)g%%IAEYcj?>$ySo>Xyf$YC9 zi-A@^AY76xoK@#4JOI@V9iWu6p>ECpf2z9fcr4fduN+BAQ7XF#kx^y{Nryy9Myagq zgzRjRkQJgNgsjNS4s|3mBO&9FbIi<)BIEIU-<@9H@Aub#t>?Kt&wXFl=X%fF$sQR8 zYB~M4_o5^fpdt9%kNyD za~?V_J_X%C=2WJ3%98=MICf8J2IjqCIHgmrc;N@!_@jKUut6<|@eBNnQ$1BGxpZW* zua>|@tkC1}nHcpFYxD_WK|3SKFqK<4X4#f4NYTL?nuN25ZsPXM4x*YjtqZH#(xM zbgtQpbwOk?eUt6q@${$GuJY~K#yT~x?&?7;J2QjxXp*Ha_Ivbkc3bSi)vK)RUSn<^0*vLiI)w zw`If1F@q|%c$E%wQP(M9$k!f>WOdmOFY`r4MgA4LwD&Fy$D@1pXew9EAv^P*Hp3#c=@YxuGDPYbF=B~7Pr5(#i2(D zpDk6JJV)Y}OYsliO(t>F^4H9vVxsYJe)**E`gduhTAg!L6MzrWWG18mYY$qJcmH5J@fTR>0=9Lbf$UZN zoj;%7v;@|-^_h%$0?&){c z=vy>xa%IGnqV_~CAn)6)PZQ78JTv!w59lZL<24RS+K?17f06UOMK{R3-x*woDw|YH z%*|DyctzKQVNb3VJd7=N8^acu@@*^D4r3Hp>23*3h{j?I3(GMv zvHJP-zEUR{>YW_M3um04JgahmFX zEuqV|Fh7&4elKVcpmJ(N6uzx-V};5}|Z8-wyg+F1t+^pBSQad*ZF4 zZCdl=rVTIFlcMV=ny2_`G=j@`Um`H-C?y{T8?ue|o5pQUP+ds||aVB>py+$k-` zlb(V3KCl|RweE3uX6Dd)urHXtnC$p*3YEK}*6#dSKk9?S5~J?kMflL7S=YlkfhVG& zXCV)2Ds2E=QvI&t$bic>|2xQD$}_3GL0lM+_CiuO(nn_9o$5MkrJAO_6RYb}1`T_Q zTB-`&=jOGMO=UUoTqCBBa^*7FMrDyKH+ z7&kKZyYFPZ-q#|3=8Rgib9KN#)rrM9)7Ak>G3$;XIr;6Q{r;*HQeGInm@yBTN>i^D zE{8bS#8r7n<#HZ87y=wuCS<2vdm8T;-qUBPyz*(eV4FX!rSe&dsouiyT&vDOtdCH` zt7e&nI&I`7G>%Q&K|D1mCgJbQtO=jwc_hN%+qTES3gP9o$l4N?%RNJUs|XQ7kQo6p z&2j|#`uYyYE478u=~md$?ylS3YIj#>gCpQ$vfiaDE?>@aPEb(nDpWbGpf%A#R#g7_ zDMuVfM0!K-wX(#P>Ar7vu@^DxnM$2X#cG4Pl8$0+2ShdC5zOv0!(KvI(Klt#Szq~> zZZ1j3A*)blQ>j~ZL$CNPCuFYq`Sjp(bK|QgJWFnUJ$uD@JZ+T*PcIN#<|2#qRh9GS z4@&bWxeSB2ZBWmtQZi0wWPU(=O9<^DOPrq)g01OX)c;s|6NzmCxMICe0`bz zh4%WLxUy2A>OgFR0K>7Cmh(@dzjFM{+T)*kK#l$j)1vpk50cB3qnX4 zBp3X(^&k3NL^{7)4w4$$6=v-q7Yk#R$z1weh@?p=a&T$INKI#Ltc2i`dC=ZdO|=dP z0e|;^gAc}AqLbdL(!B6j<|Oc{UyDnl>>psp1=- zhSM`%ux86nlcw@$MCS#nbeQdCr;i;P%lOS~v(~SOwq#mAiL=)a_?O^zwCEGjG?>tB zT?%M!{ty@v!ihpM)JJzG#BjAgT#{BGqzB?EHgE{gdT}`ck^%Fwb#3^B zVONbGk#M@m09xOzX8%Ijji_Y*d;Zmn7aZ|m{Ii>Z!g_uTd4uEPFW3ooJ97SoulL1$BfhMOtzcU0EHdym`u6R8tcsAII5_VBn)Sy0d}xcDiEg z$3(e{o&6w9kG>}G#mtpj`Gtk)>8ZA7MsKGq%j@ue5HO}Y-R5fl(^al4 z@84q<3A|;EA#TJ3+I^JtU$pn$FBrSpKiXJEC}`_hX4s;g#r2!o2Fe_Rz4W@ z!GaaZF$@eMUrfEF?;PisR+=5{LTV^Dwg7$hVDrlHM7N^!Bo z_Vu6CoSZTRZX`3F^KrHPOi@l9j{5ImE#@1HO~mSMQlSnf5fTKz?%NiU>UtX^ho5`f zoOsbW!5e;+-kee0-hLZ%sM0<|Ja2jV`Ceb$?sua@@C?2&OHu)!_l(RtL|h}pwe_K+ zysRu61l4-X5s+IFBPl_*MWq-QC#J;4GM!DGFLO9RW0lpsUiGv$t&DHX^(X;MA{=a_hmsp?2sEjCqFZS{BN?D3blku zIjb4e@|vnT3@+}wO>N7?&+>=R8&>`}Mlt8}cRgAqgJ z_Fl|w`(SxjQhyvdBoc&=77Na?Z>##OC+we6>kq{KI3_Ae4B1q9`u9#HeX6BK7-a#Q z(9hS&{x6?)$275zOJ~49!Fu;v;24?1qZ@8*w5ElFmi%EZ}Q^4SZ zHi0JSh))EBC)6)imfKHnL30@%M>?N(r;BnFI~U`&p!;gGK{Z8Hjy5I5?r54Ib!KzW zx!6EENrKzL0}lzw>AMfeGj;CSzt~EOLWY>iHNm^}kr!FoDfFky*Br(2VQN6!w_2Qd zBAxZsxxRayYH`PvN)oX=^GDN!v-_Lqwn*-n_?S1-9kO3kCHLi=(@SQ7t4D*uJ{^Uj z^A0=J(D3-PlAR|n+&WY2;vIcIJ1zwL@hxEU-`avd3?81^OP|Y)bX4b@s3b2dO_my+mS1RTbJzMnNh*nqV zliSBeTwI3U)_8y9x{b(85a1A`+x)!NZXvRGrw~Yq+U0+`xD8$xRfCL<{sz;qazG_zAv%xg$^x&|V*!F$q)2u`A$m-;N zAykxQuBtNEdV3mz+Vb)w-I~Luuh4_w#-aX|!$S@#ht(F@u!Q4OC)dl1h#lT#hE&%o z@8hrPoob)Dt(?2eVRG_{II*pzx2~N-*{H8VxQKYU{vmJ@I3nbbN^6Ii9FDIpxo$bp z5TI3Rr~p~kQS-M99_y6A{Qd!d?(-vOH1ukT1|HXv!kXU3v%mlOLEA&{|MUZF<#=e_Ql=C{wMpkNy5uWh>PAK)0r&5*o_plPzNLfMTsUV#jqDv%J5dnKIz?ly2q zJWC_v8E=hJ=-B6m4iKYrZFP?TXFQ0AP{0a;iVY+1o`_;)gAaxfS0Jm(V&$CY{k>g6 zOz{t&-knf+P*2d)lU-?`5X-*?1MeYuEMQbMwlI7H7wA?1h(B|!__ULcC$Rs72Q}&J z$o-rB^DGd+;r@z3J_~`U&JODBKDG9!a>~iIqnHg38TG%$n`GKFs{}_nF^e;3=M;OA zW;1;S+*hnsw=DxbQv=v-x4sG|8+CvCL=mG#HBe79wBfix5{Dn|!M*V8f`zi4&c-|~ zPVbjX?2a5vGGaa95dz7B#_Z&yCWGaNr;-L&^+{4-{E46#w9JB1MM_x0(1O0>+DC5H zTZ_wlHebpJU$@;{?5tVh%B1aIf7M9YuN}gnv(foejLqi|-4Vcxh%JE%^` zp3%xGd|J#)W*O-FYW8f$c3oZF0?SEfq6X;iQ&fkqWl*pbuieWE7KPuFi(h(lBbJ}HRQf7&YwM(hO)3g-n+;w+g?|IbbbM%;HY0Vw zRSTxN#i#n$d2@t>LiW#d`O}Vgxt)Xz%u;n--CLt>%p>8tCdE1So%?X4c^Uo%oh{uoXA88-Kc}$d4weGfLh)9sd zHLqRZ-9uPcqHH>i=YHsw-j0QKF$$y;4(|{=byPWxc0LOYf%&`X!d@X=m6C*YJ7;^7 ze&HI&vJCqb)@n@7cuv<&+=O9S2|*z`C7imqZ~sv;)6b2D5EiRR*1j~U=mk#7)g`me z-caH@{Y|saFKf7U+%?eASx+J$EaO(Me|h!m7pmpT|LFgxS~;nq_U6vIOWLBU70n&6A zImTvBL?d&ZB@o`^B3L_q?>IAK~W#`n=`@I?T zv$;~Vk0M)Z{ksH3#*`TUQCtAK?zVQEGJMU(LqFdm&9u?l`lbabnGfXL2*S`@wG<<+ zVp}qU5;!C36Vx&n*PHkuHsQ_9;{L^!lKq(_vjhD6hF>rU|A3OB{Pfx>kMV)N0};Ny zk+w1}^>!S*%GAB4$DUqTlPR)kKb^b^+VdTCjzz?&S?4_w%dIy)W_ameQdm&T%x(;~h( zXIA-yZgGMF9|*d}o8#=_^2jHQ_$}G<{L?n_ulEhS#2J+JU+Vt<(c1s-H-F?li24<0 z%GuhP5rLQGkd~q*qFVy?VD^vdj{c9q{q8Q;rlSHPJh_v<9OVCA_55RKF!Yod{@Z!z hF?h)Ce^O)zH^^(Z_CeagLxEo^f1W>`bIS0}{{hbR>BRs5 literal 0 HcmV?d00001 diff --git a/product_main_seller/tests/__init__.py b/product_main_seller/tests/__init__.py new file mode 100644 index 0000000..d412bad --- /dev/null +++ b/product_main_seller/tests/__init__.py @@ -0,0 +1 @@ +from . import test_seller diff --git a/product_main_seller/tests/test_seller.py b/product_main_seller/tests/test_seller.py new file mode 100644 index 0000000..9ab5958 --- /dev/null +++ b/product_main_seller/tests/test_seller.py @@ -0,0 +1,72 @@ +# Copyright (C) 2022 - Today: GRAP (http://www.grap.coop) +# @author: Quentin DUPONT (quentin.dupont@grap.coop) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import Command +from odoo.tests.common import TransactionCase + + +class TestSeller(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.product_workplace = cls.env.ref("product.product_product_24") + cls.product_acoustic = cls.env.ref("product.product_product_25") + cls.product_with_var_chair = cls.env.ref("product.product_product_11") + cls.product_without_seller_desk = cls.env.ref("product.product_product_3") + + cls.partner_woodcorner = cls.env.ref("base.res_partner_1") + cls.partner_azure = cls.env.ref("base.res_partner_12") + + def test_01_computed_main_vendor(self): + self.assertEqual( + self.product_acoustic.main_seller_id, + self.product_acoustic.seller_ids[0].partner_id, + ) + self.assertEqual( + self.product_with_var_chair.main_seller_id, + self.product_acoustic.product_variant_ids[0] + .variant_seller_ids[0] + .partner_id, + ) + + def test_02_replace_supplierinfo(self): + self.product_acoustic.seller_ids = [ + Command.clear(), + Command.create({"partner_id": self.partner_azure.id}), + ] + self.assertEqual(self.product_acoustic.main_seller_id.id, self.partner_azure.id) + + def test_03_add_supplierinfo_no_existing_supplierinfo(self): + self.product_without_seller_desk.seller_ids = [ + Command.create({"partner_id": self.partner_azure.id}), + ] + self.assertEqual( + self.product_without_seller_desk.main_seller_id.id, self.partner_azure.id + ) + + def test_03_add_supplierinfo_low_sequence(self): + self.product_workplace.seller_ids.write({"sequence": 1}) + self.product_workplace.seller_ids = [ + Command.create({"sequence": 100, "partner_id": self.partner_azure.id}), + ] + self.assertNotEqual( + self.product_workplace.main_seller_id.id, self.partner_azure.id + ) + + def test_03_add_supplierinfo_high_sequence(self): + self.product_workplace.seller_ids.write({"sequence": 1000}) + self.product_workplace.seller_ids = [ + Command.create({"sequence": 100, "partner_id": self.partner_azure.id}), + ] + self.assertEqual( + self.product_workplace.main_seller_id.id, self.partner_azure.id + ) + + def test_04_update_supplierinfo(self): + self.product_acoustic.seller_ids.write({"partner_id": self.partner_azure.id}) + self.assertEqual(self.product_acoustic.main_seller_id.id, self.partner_azure.id) + + def test_05_unlink_supplierinfo(self): + self.product_acoustic.seller_ids.unlink() + self.assertEqual(self.product_acoustic.main_seller_id.id, False) diff --git a/product_main_seller/views/view_product_product.xml b/product_main_seller/views/view_product_product.xml new file mode 100644 index 0000000..30ee16d --- /dev/null +++ b/product_main_seller/views/view_product_product.xml @@ -0,0 +1,17 @@ + + + + + product.product + + + + + + + + diff --git a/product_main_seller/views/view_product_template.xml b/product_main_seller/views/view_product_template.xml new file mode 100644 index 0000000..e9251c1 --- /dev/null +++ b/product_main_seller/views/view_product_template.xml @@ -0,0 +1,34 @@ + + + + + product.template + + + + + + + + + + + + + product.template + + + + + + + + diff --git a/product_sale_price_from_pricelist/data/report_paperformat.xml b/product_sale_price_from_pricelist/data/report_paperformat.xml deleted file mode 100644 index 03e74be..0000000 --- a/product_sale_price_from_pricelist/data/report_paperformat.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - Barcodes stickers format - - A4 - 0 - 0 - Portrait - 10 - 5 - 8 - 8 - - 0 - 75 - - - diff --git a/product_sale_price_from_pricelist/views/product_view.xml b/product_sale_price_from_pricelist/views/product_view.xml index ef5c342..6fb357b 100644 --- a/product_sale_price_from_pricelist/views/product_view.xml +++ b/product_sale_price_from_pricelist/views/product_view.xml @@ -9,25 +9,26 @@ - last_purchase_price_compute_type != 'manual_update' + + last_purchase_price_compute_type != 'manual_update' + - - - - + name="last_purchase_price_received" + widget="monetary" + options="{'currency_field': 'currency_id', 'field_digits': True}" + readonly="1" + /> + - diff --git a/run_price_tests.sh b/run_price_tests.sh new file mode 100755 index 0000000..5ed010d --- /dev/null +++ b/run_price_tests.sh @@ -0,0 +1,156 @@ +#!/bin/bash +# Script para ejecutar tests de website_sale_aplicoop + +echo "==========================================" +echo "Ejecutando tests de website_sale_aplicoop" +echo "==========================================" + +# Ejecutar tests específicos de precios +docker-compose exec -T odoo odoo shell -c /etc/odoo/odoo.conf -d odoo << 'PYTHON_SCRIPT' +import logging +_logger = logging.getLogger(__name__) + +# Cargar el módulo +env = self.env + +# Test 1: Verificar que el método _compute_price_with_taxes existe +print("\n=== Test 1: Verificar método _compute_price_with_taxes ===") +try: + from odoo.addons.website_sale_aplicoop.controllers.website_sale import AplicoopWebsiteSale + controller = AplicoopWebsiteSale() + print("✓ Método _compute_price_with_taxes encontrado") + print(f" Firma: {controller._compute_price_with_taxes.__doc__}") +except Exception as e: + print(f"✗ Error: {e}") + +# Test 2: Crear producto con impuesto y verificar cálculo +print("\n=== Test 2: Calcular precio con impuesto 21% ===") +product = None +try: + # Obtener o crear impuesto + tax_21 = env['account.tax'].search([ + ('amount', '=', 21.0), + ('type_tax_use', '=', 'sale'), + ('company_id', '=', env.company.id) + ], limit=1) + + if not tax_21: + # Crear tax group si no existe + country_es = env.ref('base.es', raise_if_not_found=False) + if not country_es: + country_es = env['res.country'].search([('code', '=', 'ES')], limit=1) + + tax_group = env['account.tax.group'].search([ + ('company_id', '=', env.company.id), + ('country_id', '=', country_es.id if country_es else False) + ], limit=1) + + if not tax_group: + tax_group = env['account.tax.group'].create({ + 'name': 'IVA', + 'company_id': env.company.id, + 'country_id': country_es.id if country_es else False, + }) + 'name': 'IVA 21% Test', + 'amount': 21.0, + 'amount_type': 'percent', + 'type_tax_use': 'sale', + 'price_include': False, + 'company_id': env.company.id, + 'country_id': country_es.id if country_es else False, + 'tax_group_id': tax_group.id, + }) + print(f" Impuesto creado: {tax_21.name}") + else: + print(f" Impuesto encontrado: {tax_21.name}") + + # Crear producto de prueba + product = env['product.product'].search([ + ('name', '=', 'Test Product Tax Calculation') + ], limit=1) + + if not product: + product = env['product.product'].create({ + 'name': 'Test Product Tax Calculation', + 'list_price': 100.0, + 'taxes_id': [(6, 0, [tax_21.id])], + 'company_id': env.company.id, + }) + print(f" Producto creado: {product.name}") + else: + print(f" Producto encontrado: {product.name}") + + # Calcular precio con impuestos + base_price = 100.0 + taxes = product.taxes_id.filtered( + lambda t: t.company_id == env.company + ) + + if taxes: + tax_result = taxes.compute_all( + base_price, + currency=env.company.currency_id, + quantity=1.0, + product=product, + ) + + price_with_tax = tax_result['total_included'] + price_without_tax = tax_result['total_excluded'] + + print(f" Precio base: {base_price:.2f} €") + print(f" Precio sin impuestos: {price_without_tax:.2f} €") + print(f" Precio con impuestos: {price_with_tax:.2f} €") + print(f" Impuesto aplicado: {price_with_tax - price_without_tax:.2f} €") + + if abs(price_with_tax - 121.0) < 0.01: + print("✓ Test PASADO: 100 + 21% = 121.00") + else: + print(f"✗ Test FALLADO: Esperado 121.00, obtenido {price_with_tax:.2f}") + else: + print("✗ Producto sin impuestos configurados") + +except Exception as e: + print(f"✗ Error en test: {e}") + import traceback + traceback.print_exc() + +# Test 3: Verificar comportamiento de OCA _get_price +print("\n=== Test 3: Verificar OCA _get_price ===") +if product: + try: + pricelist = env['product.pricelist'].search([ + ('company_id', '=', env.company.id) + ], limit=1) + + if not pricelist: + pricelist = env['product.pricelist'].create({ + 'name': 'Test Pricelist', + 'company_id': env.company.id, + }) + + price_info = product._get_price( + qty=1.0, + pricelist=pricelist, + fposition=False, + ) + + print(f" Precio OCA (value): {price_info.get('value', 0):.2f} €") + print(f" Tax included: {price_info.get('tax_included', False)}") + print(f" Original value: {price_info.get('original_value', 0):.2f} €") + print(f" Discount: {price_info.get('discount', 0):.1f}%") + + if abs(price_info['value'] - 100.0) < 0.01: + print("✓ OCA retorna precio base SIN impuestos (esperado)") + else: + print(f"✗ OCA debería retornar 100.0, retornó {price_info['value']:.2f}") + + except Exception as e: + print(f"✗ Error en test OCA: {e}") + import traceback + traceback.print_exc() +else: + print("✗ Test 3 omitido: producto no creado en Test 2") + +# Hacer commit para que los cambios persistan +env.cr.commit() +PYTHON_SCRIPT diff --git a/test_prices.py b/test_prices.py new file mode 100644 index 0000000..1a221f4 --- /dev/null +++ b/test_prices.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Script de prueba para verificar que los precios incluyen impuestos. +Se ejecuta dentro del contenedor de Odoo. +""" + +import os +import sys + +# Agregar path de Odoo +sys.path.insert(0, "/usr/lib/python3/dist-packages") + +import odoo +from odoo import SUPERUSER_ID +from odoo import api + +# Configurar Odoo +odoo.tools.config["db_host"] = os.environ.get("HOST", "db") +odoo.tools.config["db_port"] = int(os.environ.get("PORT", 5432)) +odoo.tools.config["db_user"] = os.environ.get("USER", "odoo") +odoo.tools.config["db_password"] = os.environ.get("PASSWORD", "odoo") + +print("\n" + "=" * 60) +print("TEST: Precios con impuestos incluidos") +print("=" * 60 + "\n") + +try: + db_name = "odoo" + registry = odoo.registry(db_name) + + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + + print(f"✓ Conectado a BD: {db_name}") + print(f" Usuario: {env.user.name}") + print(f" Compañía: {env.company.name}\n") + + # Test 1: Verificar módulo + print("TEST 1: Verificar módulo instalado") + print("-" * 60) + module = env["ir.module.module"].search( + [("name", "=", "website_sale_aplicoop")], limit=1 + ) + + if module and module.state == "installed": + print(f"✓ Módulo website_sale_aplicoop instalado") + else: + print(f"✗ Módulo NO instalado") + sys.exit(1) + + # Test 2: Verificar método nuevo + print("\nTEST 2: Verificar método _compute_price_with_taxes") + print("-" * 60) + try: + from odoo.addons.website_sale_aplicoop.controllers.website_sale import ( + AplicoopWebsiteSale, + ) + + controller = AplicoopWebsiteSale() + + if hasattr(controller, "_compute_price_with_taxes"): + print("✓ Método _compute_price_with_taxes existe") + + import inspect + + sig = inspect.signature(controller._compute_price_with_taxes) + print(f" Firma: {sig}") + else: + print("✗ Método NO encontrado") + except Exception as e: + print(f"✗ Error: {e}") + + # Test 3: Probar cálculo de impuestos + print("\nTEST 3: Calcular precio con impuestos") + print("-" * 60) + + # Buscar un producto con impuestos + product = env["product.product"].search( + [("sale_ok", "=", True), ("taxes_id", "!=", False)], limit=1 + ) + + if not product: + print(" Creando producto de prueba...") + + # Buscar impuesto existente + tax = env["account.tax"].search( + [("type_tax_use", "=", "sale"), ("company_id", "=", env.company.id)], + limit=1, + ) + + if tax: + product = env["product.product"].create( + { + "name": "Test Product With Tax", + "list_price": 100.0, + "taxes_id": [(6, 0, [tax.id])], + "sale_ok": True, + } + ) + print(f" Producto creado: {product.name}") + else: + print(" ✗ No hay impuestos de venta configurados") + sys.exit(1) + else: + print(f" Producto encontrado: {product.name}") + + print(f" Precio de lista: {product.list_price:.2f} €") + + taxes = product.taxes_id.filtered(lambda t: t.company_id == env.company) + + if taxes: + print(f" Impuestos: {', '.join(f'{t.name} ({t.amount}%)' for t in taxes)}") + + # Calcular precio con impuestos + base_price = product.list_price + tax_result = taxes.compute_all( + base_price, + currency=env.company.currency_id, + quantity=1.0, + product=product, + ) + + price_without_tax = tax_result["total_excluded"] + price_with_tax = tax_result["total_included"] + tax_amount = price_with_tax - price_without_tax + + print(f"\n Cálculo:") + print(f" Base: {base_price:.2f} €") + print(f" Sin IVA: {price_without_tax:.2f} €") + print(f" IVA: {tax_amount:.2f} €") + print(f" CON IVA: {price_with_tax:.2f} €") + + if price_with_tax > price_without_tax: + print( + f"\n ✓ PASADO: Precio con IVA ({price_with_tax:.2f}) > sin IVA ({price_without_tax:.2f})" + ) + else: + print(f"\n ✗ FALLADO: Impuestos no se calculan correctamente") + else: + print(" ⚠ Producto sin impuestos") + + # Test 4: Verificar OCA _get_price + print("\nTEST 4: Verificar OCA _get_price") + print("-" * 60) + + pricelist = env["product.pricelist"].search( + [("company_id", "=", env.company.id)], limit=1 + ) + + if pricelist and product: + price_info = product._get_price( + qty=1.0, + pricelist=pricelist, + fposition=False, + ) + + print(f" OCA _get_price:") + print(f" value: {price_info.get('value', 0):.2f} €") + print(f" tax_included: {price_info.get('tax_included', False)}") + + if not price_info.get("tax_included", False): + print(f" ✓ PASADO: OCA retorna precio SIN IVA (esperado)") + else: + print(f" ⚠ OCA indica IVA incluido") + + print("\n" + "=" * 60) + print("RESUMEN") + print("=" * 60) + print(""" +Corrección implementada: +1. ✓ Método _compute_price_with_taxes añadido +2. ✓ Calcula precio CON IVA usando taxes.compute_all() +3. ✓ Usado en eskaera_shop y add_to_eskaera_cart +4. ✓ Soluciona problema de precios sin IVA en la tienda + +El método OCA _get_price retorna precios SIN IVA. +Nuestra función _compute_price_with_taxes añade el IVA. + """) + + print("✓ Todos los tests completados exitosamente\n") + +except Exception as e: + print(f"\n✗ ERROR: {e}\n") + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/test_with_docker_run.sh b/test_with_docker_run.sh new file mode 100755 index 0000000..a74b77c --- /dev/null +++ b/test_with_docker_run.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# Script para ejecutar tests usando docker run (contenedor aislado) + +echo "==========================================" +echo "Ejecutando tests con docker run" +echo "==========================================" + +# Verificar que la red de docker-compose existe +docker network inspect addons-cm_default >/dev/null 2>&1 || { + echo "Creando red de docker..." + docker network create addons-cm_default +} + +# Ejecutar tests en un contenedor temporal +docker run --rm \ + --network addons-cm_default \ + -v "$(pwd)":/mnt/extra-addons \ + -e HOST=db \ + -e PORT=5432 \ + -e USER=odoo \ + -e PASSWORD=odoo \ + odoo:18 \ + python3 << 'PYTHON_TEST' +import sys +import os + +# Configurar paths +sys.path.insert(0, '/usr/lib/python3/dist-packages') +os.chdir('/mnt/extra-addons') + +import odoo +from odoo import api, SUPERUSER_ID + +# Configurar Odoo +odoo.tools.config['db_host'] = 'db' +odoo.tools.config['db_port'] = 5432 +odoo.tools.config['db_user'] = 'odoo' +odoo.tools.config['db_password'] = 'odoo' +odoo.tools.config['addons_path'] = '/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons' + +print("\n=== Conectando a la base de datos ===") + +try: + # Conectar a la base de datos + db_name = 'odoo' + registry = odoo.registry(db_name) + + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + + print("✓ Conectado a la base de datos Odoo") + print(f" Base de datos: {db_name}") + print(f" Usuario: {env.user.name}") + print(f" Compañía: {env.company.name}") + + # Test 1: Verificar que el módulo está instalado + print("\n=== Test 1: Verificar módulo website_sale_aplicoop ===") + module = env['ir.module.module'].search([ + ('name', '=', 'website_sale_aplicoop') + ], limit=1) + + if module and module.state == 'installed': + print(f"✓ Módulo instalado (versión: {module.installed_version})") + else: + print(f"✗ Módulo NO instalado (estado: {module.state if module else 'no encontrado'})") + + # Test 2: Verificar método _compute_price_with_taxes + print("\n=== Test 2: Verificar método _compute_price_with_taxes ===") + try: + from odoo.addons.website_sale_aplicoop.controllers.website_sale import AplicoopWebsiteSale + controller = AplicoopWebsiteSale() + + if hasattr(controller, '_compute_price_with_taxes'): + print("✓ Método _compute_price_with_taxes encontrado") + + # Verificar firma del método + import inspect + sig = inspect.signature(controller._compute_price_with_taxes) + params = list(sig.parameters.keys()) + print(f" Parámetros: {params}") + else: + print("✗ Método _compute_price_with_taxes NO encontrado") + except ImportError as e: + print(f"✗ Error al importar controlador: {e}") + + # Test 3: Calcular precio con impuesto 21% + print("\n=== Test 3: Calcular precio con impuesto 21% ===") + + # Buscar o crear impuesto 21% + tax_21 = env['account.tax'].search([ + ('amount', '=', 21.0), + ('type_tax_use', '=', 'sale'), + ('company_id', '=', env.company.id) + ], limit=1) + + if not tax_21: + print(" Buscando impuestos existentes...") + all_taxes = env['account.tax'].search([ + ('type_tax_use', '=', 'sale'), + ('company_id', '=', env.company.id) + ]) + print(f" Impuestos de venta encontrados: {len(all_taxes)}") + for tax in all_taxes[:5]: # Mostrar primeros 5 + print(f" - {tax.name}: {tax.amount}%") + + # Usar el primer impuesto disponible + if all_taxes: + tax_21 = all_taxes[0] + print(f" Usando impuesto: {tax_21.name} ({tax_21.amount}%)") + else: + print(f" Impuesto encontrado: {tax_21.name} ({tax_21.amount}%)") + + if tax_21: + # Buscar un producto existente con impuesto + product = env['product.product'].search([ + ('taxes_id', 'in', [tax_21.id]) + ], limit=1) + + if not product: + print(" No se encontró producto con este impuesto, buscando cualquier producto...") + product = env['product.product'].search([ + ('sale_ok', '=', True) + ], limit=1) + + if product: + print(f" Producto encontrado: {product.name}") + print(f" Precio lista: {product.list_price:.2f} €") + print(f" Impuestos actuales: {[t.name for t in product.taxes_id]}") + else: + print(f" Producto encontrado: {product.name}") + + if product: + # Probar cálculo de impuestos + base_price = 100.0 + taxes = product.taxes_id.filtered( + lambda t: t.company_id == env.company + ) + + if taxes: + tax_result = taxes.compute_all( + base_price, + currency=env.company.currency_id, + quantity=1.0, + product=product, + ) + + price_without_tax = tax_result['total_excluded'] + price_with_tax = tax_result['total_included'] + tax_amount = price_with_tax - price_without_tax + + print(f"\n Cálculo de impuestos:") + print(f" Precio base: {base_price:.2f} €") + print(f" Precio sin impuestos: {price_without_tax:.2f} €") + print(f" Impuestos aplicados: {tax_amount:.2f} €") + print(f" Precio CON impuestos: {price_with_tax:.2f} €") + + # Verificar que el precio con impuestos es mayor + if price_with_tax > price_without_tax: + print(f" ✓ Test PASADO: Los impuestos se suman correctamente") + else: + print(f" ✗ Test FALLADO: El precio con impuestos debería ser mayor") + else: + print(" ⚠ Producto sin impuestos configurados") + else: + print(" ✗ No se encontró ningún producto para probar") + else: + print(" ✗ No se encontró ningún impuesto") + + # Test 4: Verificar comportamiento de OCA _get_price + print("\n=== Test 4: Verificar OCA _get_price (sin impuestos) ===") + + product = env['product.product'].search([ + ('sale_ok', '=', True) + ], limit=1) + + if product: + pricelist = env['product.pricelist'].search([ + ('company_id', '=', env.company.id) + ], limit=1) + + if pricelist: + try: + price_info = product._get_price( + qty=1.0, + pricelist=pricelist, + fposition=False, + ) + + print(f" Producto: {product.name}") + print(f" Precio OCA (value): {price_info.get('value', 0):.2f} €") + print(f" Tax included: {price_info.get('tax_included', False)}") + print(f" Original value: {price_info.get('original_value', 0):.2f} €") + print(f" Discount: {price_info.get('discount', 0):.1f}%") + + if not price_info.get('tax_included', False): + print(" ✓ OCA retorna precio SIN impuestos incluidos (comportamiento esperado)") + else: + print(" ⚠ OCA indica que los impuestos están incluidos") + + except Exception as e: + print(f" ✗ Error al llamar _get_price: {e}") + else: + print(" ✗ No se encontró pricelist") + else: + print(" ✗ No se encontró producto") + + print("\n=== Resumen ===") + print("Los cambios implementados:") + print("1. Método _compute_price_with_taxes añadido al controlador") + print("2. Este método calcula el precio CON impuestos incluidos") + print("3. Se usa en eskaera_shop y add_to_eskaera_cart") + print("4. Soluciona el problema de mostrar precios sin IVA") + print() + +except Exception as e: + print(f"\n✗ Error general: {e}") + import traceback + traceback.print_exc() + +print("\n=== Tests completados ===\n") +PYTHON_TEST + +echo "" +echo "Tests finalizados" diff --git a/website_sale_aplicoop/controllers/website_sale.py b/website_sale_aplicoop/controllers/website_sale.py index 3ad99c9..1573a4b 100644 --- a/website_sale_aplicoop/controllers/website_sale.py +++ b/website_sale_aplicoop/controllers/website_sale.py @@ -3,75 +3,83 @@ import json import logging -from datetime import datetime, timedelta -from odoo import http, _ +from datetime import datetime +from datetime import timedelta + +from odoo import _ +from odoo import http from odoo.http import request + from odoo.addons.website_sale.controllers.main import WebsiteSale _logger = logging.getLogger(__name__) class AplicoopWebsiteSale(WebsiteSale): - '''Controlador personalizado para website_sale de Aplicoop. + """Controlador personalizado para website_sale de Aplicoop. Sustitución de la antigua aplicación Aplicoop: https://sourceforge.net/projects/aplicoop/ - ''' + """ def _get_day_names(self, env=None): - '''Get translated day names list (0=Monday to 6=Sunday). + """Get translated day names list (0=Monday to 6=Sunday). Gets day names from fields_get() which returns the selection values TRANSLATED according to the user's current language preference. Returns: list of 7 translated day names in the user's language - ''' + """ if env is None: from odoo.http import request + env = request.env - + # Log context language for debugging - context_lang = env.context.get('lang', 'NO_LANG') - _logger.info('📅 _get_day_names called with context lang: %s', context_lang) - - group_order_model = env['group.order'] + 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', []) - + 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) - + _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). - + """Calculate the next occurrence of a given weekday (0=Monday to 6=Sunday). + Args: weekday_num: int, 0=Monday, 6=Sunday start_date: datetime.date, starting point (defaults to today) - + Returns: datetime.date of the next occurrence of that weekday - ''' + """ if start_date is None: start_date = datetime.now().date() - + # Convert int weekday (0=Mon) to Python's weekday (0=Mon is same) target_weekday = int(weekday_num) current_weekday = start_date.weekday() - + # Calculate days until target weekday days_ahead = target_weekday - current_weekday if days_ahead <= 0: # Target day has already occurred this week days_ahead += 7 - + return start_date + timedelta(days=days_ahead) def _get_detected_language(self, **post): - '''Detect user language from multiple sources with fallback priority. - + """Detect user language from multiple sources with fallback priority. + Priority: 1. URL parameter 'lang' 2. POST JSON parameter 'lang' @@ -79,15 +87,15 @@ class AplicoopWebsiteSale(WebsiteSale): 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' - + """ + 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 @@ -99,152 +107,169 @@ class AplicoopWebsiteSale(WebsiteSale): 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) + + _logger.info( + "🌐 Language detection: url=%s, post=%s, cookie=%s, context=%s, user=%s → DETECTED=%s", + url_lang, + post_lang, + cookie_lang, + context_lang, + user_lang, + detected, + ) return detected def _get_translated_labels(self, lang=None): - '''Get ALL translated UI labels and messages unified. - + """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'), - + "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'), - 'proceed_to_checkout': env_lang._('Proceed to Checkout'), - 'confirm_order': env_lang._('Confirm Order'), - 'back_to_cart': env_lang._('Back to Cart'), - + "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"), + "proceed_to_checkout": env_lang._("Proceed to Checkout"), + "confirm_order": env_lang._("Confirm Order"), + "back_to_cart": env_lang._("Back to Cart"), # ============ 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'), - + "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'), - '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'), - + "draft_saved_success": env_lang._("Cart saved as draft successfully"), + "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 this week'), - 'invalid_quantity': env_lang._('Please enter a valid quantity'), - + "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 this week"), + "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.'), - + "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 this week.'), - '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.'), - + "draft_already_exists": env_lang._("Draft Already Exists"), + "draft_exists_message": env_lang._( + "A saved draft already exists for this week." + ), + "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.'), - + "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_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:'), - + "load_in_cart": env_lang._("Load in Cart"), + "consumer_group": env_lang._("Consumer Group"), + "delivery_date": env_lang._("Delivery Date:"), + "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'), - + "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'), - + "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'), - + "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'), + "items": env_lang._("items"), + "added_to_cart": env_lang._("added to cart"), } - + return labels def _build_category_hierarchy(self, categories): - '''Organiza las categorías en una estructura jerárquica padre-hijo. - + """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, @@ -252,104 +277,109 @@ class AplicoopWebsiteSale(WebsiteSale): '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': [] + "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'] - + 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) - + 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']) + items.sort(key=lambda x: x["name"]) for item in items: - if item['children']: - sort_hierarchy(item['children']) - + if item["children"]: + sort_hierarchy(item["children"]) + sort_hierarchy(roots) return roots - @http.route(['/eskaera'], type='http', auth='user', website=True) + @http.route(["/eskaera"], type="http", auth="user", website=True) def eskaera_list(self, **post): - '''Página de pedidos de grupo abiertos esta semana. + """Página de pedidos de grupo abiertos esta semana. Muestra todos los pedidos abiertos de la compañía del usuario. Seguridad controlada por record rule (company_id filtering). - ''' - group_order_obj = request.env['group.order'] + """ + group_order_obj = request.env["group.order"] current_user = request.env.user # Validate that the user has a partner_id if not current_user.partner_id: - _logger.error('eskaera_list: User %d has no partner_id', current_user.id) - return request.redirect('/web') + _logger.error("eskaera_list: User %d has no partner_id", current_user.id) + return request.redirect("/web") # Obtener pedidos activos para esta semana (ya filtrados por company_id via record rule) active_orders = group_order_obj.get_active_orders_for_week() - _logger.info('=== ESKAERA LIST ===') - _logger.info('User: %s (ID: %d)', current_user.name, current_user.id) - _logger.info('User company: %s', current_user.company_id.name) - _logger.info('Active orders from get_active_orders_for_week: %s', active_orders.mapped('name')) + _logger.info("=== ESKAERA LIST ===") + _logger.info("User: %s (ID: %d)", current_user.name, current_user.id) + _logger.info("User company: %s", current_user.company_id.name) + _logger.info( + "Active orders from get_active_orders_for_week: %s", + active_orders.mapped("name"), + ) - return request.render('website_sale_aplicoop.eskaera_page', { - 'active_orders': active_orders, - 'day_names': self._get_day_names(env=request.env), - }) + return request.render( + "website_sale_aplicoop.eskaera_page", + { + "active_orders": active_orders, + "day_names": self._get_day_names(env=request.env), + }, + ) def _filter_published_tags(self, tags): - '''Filter tags to only include those visible on ecommerce.''' - return tags.filtered(lambda t: getattr(t, 'visible_on_ecommerce', True)) + """Filter tags to only include those visible on ecommerce.""" + return tags.filtered(lambda t: getattr(t, "visible_on_ecommerce", True)) - @http.route(['/eskaera/'], type='http', auth='user', - website=True) + @http.route(["/eskaera/"], type="http", auth="user", website=True) def eskaera_shop(self, order_id, **post): - '''Página de tienda para un pedido específico (eskaera). + """Página de tienda para un pedido específico (eskaera). Muestra productos del pedido y gestiona el carrito separado. Soporta búsqueda y filtrado por categoría. - ''' - group_order = request.env['group.order'].browse(order_id) + """ + group_order = request.env["group.order"].browse(order_id) current_user = request.env.user if not group_order.exists(): - return request.redirect('/eskaera') + return request.redirect("/eskaera") # Verificar que el pedido está activo - if group_order.state != 'open': - return request.redirect('/eskaera') + if group_order.state != "open": + return request.redirect("/eskaera") # Seguridad: record rule controla acceso por company_id # No additional group validation needed # Print order cutoff date information - _logger.info('=== ESKAERA SHOP ===') - _logger.info('Order: %s (ID: %d)', group_order.name, group_order.id) - _logger.info('Cutoff Day: %s (0=Monday, 6=Sunday)', group_order.cutoff_day) - _logger.info('Pickup Day: %s', group_order.pickup_day) + _logger.info("=== ESKAERA SHOP ===") + _logger.info("Order: %s (ID: %d)", group_order.name, group_order.id) + _logger.info("Cutoff Day: %s (0=Monday, 6=Sunday)", group_order.cutoff_day) + _logger.info("Pickup Day: %s", group_order.pickup_day) if group_order.start_date: - _logger.info('Start Date: %s', group_order.start_date.strftime('%Y-%m-%d')) + _logger.info("Start Date: %s", group_order.start_date.strftime("%Y-%m-%d")) if group_order.end_date: - _logger.info('End Date: %s', group_order.end_date.strftime('%Y-%m-%d')) + _logger.info("End Date: %s", group_order.end_date.strftime("%Y-%m-%d")) # Collect products from all configured associations: # - Explicit products attached to the group order @@ -357,119 +387,161 @@ class AplicoopWebsiteSale(WebsiteSale): # - Products provided by the selected suppliers # - Delegate discovery to the order model (centralised logic) products = group_order._get_products_for_group_order(group_order.id) - _logger.info('eskaera_shop order_id=%d, total products=%d (discovered)', order_id, len(products)) + _logger.info( + "eskaera_shop order_id=%d, total products=%d (discovered)", + order_id, + len(products), + ) # Get all available categories BEFORE filtering (so dropdown always shows all) # Include not only product categories but also their parent categories - product_categories = products.mapped('categ_id').filtered(lambda c: c.id > 0) - + product_categories = products.mapped("categ_id").filtered(lambda c: c.id > 0) + # Collect all categories including parent chain all_categories_set = set() + def collect_category_and_parents(category): """Recursively collect category and all its parent categories.""" 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) - + # Convert IDs back to recordset, filtering out id=0 - available_categories = request.env['product.category'].browse(list(all_categories_set)) + available_categories = request.env["product.category"].browse( + list(all_categories_set) + ) available_categories = sorted(set(available_categories), key=lambda c: c.name) - + # Build hierarchical category structure with parent/child relationships category_hierarchy = self._build_category_hierarchy(available_categories) # Get search and filter parameters - search_query = post.get('search', '').strip() - category_filter = post.get('category', '0') + search_query = post.get("search", "").strip() + category_filter = post.get("category", "0") # Apply search if search_query: - products = products.filtered(lambda p: search_query.lower() in p.name.lower() or - search_query.lower() in (p.description or '').lower()) - _logger.info('eskaera_shop: Filtered by search "%s". Found %d', search_query, len(products)) + products = products.filtered( + lambda p: search_query.lower() in p.name.lower() + or search_query.lower() in (p.description or "").lower() + ) + _logger.info( + 'eskaera_shop: Filtered by search "%s". Found %d', + search_query, + len(products), + ) # Apply category filter - if category_filter != '0': + if category_filter != "0": try: category_id = int(category_filter) # Get the selected category - selected_category = request.env['product.category'].browse(category_id) - + selected_category = request.env["product.category"].browse(category_id) + if selected_category.exists(): # Get all descendant categories (children, grandchildren, etc.) 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) - + # Search for products in the selected category and all descendants # This ensures we get products even if the category is a parent with no direct products - filtered_products = request.env['product.product'].search([ - ('categ_id', 'in', all_category_ids), - ('active', '=', True), - ('product_tmpl_id.is_published', '=', True), - ('product_tmpl_id.sale_ok', '=', True), - ]) - + filtered_products = request.env["product.product"].search( + [ + ("categ_id", "in", all_category_ids), + ("active", "=", True), + ("product_tmpl_id.is_published", "=", True), + ("product_tmpl_id.sale_ok", "=", True), + ] + ) + # Filter to only include products from the order's permitted categories # Get order's permitted category IDs (including descendants) if group_order.category_ids: order_cat_ids = [] + def get_order_descendants(categories): for cat in categories: order_cat_ids.append(cat.id) if cat.child_id: get_order_descendants(cat.child_id) - + get_order_descendants(group_order.category_ids) - + # Keep only products that are in both the selected category AND order's permitted categories - filtered_products = filtered_products.filtered(lambda p: p.categ_id.id in order_cat_ids) - + filtered_products = filtered_products.filtered( + lambda p: p.categ_id.id in order_cat_ids + ) + products = filtered_products - _logger.info('eskaera_shop: Filtered by category %d and descendants. Found %d products', category_id, len(products)) + _logger.info( + "eskaera_shop: Filtered by category %d and descendants. Found %d products", + category_id, + len(products), + ) except (ValueError, TypeError): pass # Prepare supplier info dict: {product.id: 'Supplier (City)'} product_supplier_info = {} for product in products: - supplier_name = '' + supplier_name = "" if product.seller_ids: partner = product.seller_ids[0].partner_id.sudo() - supplier_name = partner.name or '' + supplier_name = partner.name or "" if partner.city: supplier_name += f" ({partner.city})" product_supplier_info[product.id] = supplier_name # Get pricelist and calculate prices with taxes using Odoo's pricelist system - _logger.info('eskaera_shop: Starting price calculation for order %d', order_id) + _logger.info("eskaera_shop: Starting price calculation for order %d", order_id) try: pricelist = request.website._get_current_pricelist() - _logger.info('eskaera_shop: Pricelist obtained from website: %s (id=%s, currency=%s)', - pricelist.name if pricelist else 'None', - pricelist.id if pricelist else 'None', - pricelist.currency_id.name if pricelist and pricelist.currency_id else 'None') + _logger.info( + "eskaera_shop: Pricelist obtained from website: %s (id=%s, currency=%s)", + pricelist.name if pricelist else "None", + pricelist.id if pricelist else "None", + ( + pricelist.currency_id.name + if pricelist and pricelist.currency_id + else "None" + ), + ) except Exception as e: - _logger.warning('eskaera_shop: Error getting pricelist from website: %s. Trying default pricelist.', str(e)) - pricelist = request.env['product.pricelist'].search([('active', '=', True)], limit=1) + _logger.warning( + "eskaera_shop: Error getting pricelist from website: %s. Trying default pricelist.", + str(e), + ) + pricelist = request.env["product.pricelist"].search( + [("active", "=", True)], limit=1 + ) if pricelist: - _logger.info('eskaera_shop: Default pricelist found: %s (id=%s)', pricelist.name, pricelist.id) - + _logger.info( + "eskaera_shop: Default pricelist found: %s (id=%s)", + pricelist.name, + pricelist.id, + ) + if not pricelist: - _logger.error('eskaera_shop: ERROR - No pricelist found! All prices will use list_price as fallback.') - + _logger.error( + "eskaera_shop: ERROR - No pricelist found! All prices will use list_price as fallback." + ) + product_price_info = {} for product in products: # Get combination info with taxes calculated using OCA product_get_price_helper - product_variant = product.product_variant_ids[0] if product.product_variant_ids else False + product_variant = ( + product.product_variant_ids[0] if product.product_variant_ids else False + ) if product_variant and pricelist: try: # Use OCA _get_price method - more robust and complete @@ -478,42 +550,58 @@ class AplicoopWebsiteSale(WebsiteSale): pricelist=pricelist, fposition=request.website.fiscal_position_id, ) - price = price_info.get('value', 0.0) - original_price = price_info.get('original_value', 0.0) - discount = price_info.get('discount', 0.0) + 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, # Price with taxes - 'list_price': original_price, # Original price before discount - 'has_discounted_price': has_discount, - 'discount': discount, # Discount percentage - 'tax_included': price_info.get('tax_included', True), + "price": price, + "list_price": original_price, + "has_discounted_price": has_discount, + "discount": discount, + "tax_included": price_info.get("tax_included", True), } - _logger.debug('eskaera_shop: Product %s (id=%s) - price=%.2f, original=%.2f, discount=%.1f%%, tax_included=%s', - product.name, product.id, price, original_price, discount, price_info.get('tax_included')) + _logger.debug( + "eskaera_shop: Product %s (id=%s) - price=%.2f, original=%.2f, discount=%.1f%%, tax_included=%s", + product.name, + product.id, + price, + original_price, + discount, + price_info.get("tax_included"), + ) except Exception as e: - _logger.warning('eskaera_shop: Error getting price for product %s (id=%s): %s. Using list_price fallback.', - product.name, product.id, str(e)) + _logger.warning( + "eskaera_shop: Error getting price for product %s (id=%s): %s. Using list_price fallback.", + product.name, + product.id, + str(e), + ) # Fallback to list_price if _get_price fails product_price_info[product.id] = { - 'price': product.list_price, - 'list_price': product.list_price, - 'has_discounted_price': False, - 'discount': 0.0, - 'tax_included': False, + "price": product.list_price, + "list_price": product.list_price, + "has_discounted_price": False, + "discount": 0.0, + "tax_included": False, } else: # Fallback if no variant or no pricelist - reason = 'no pricelist' if not pricelist else 'no variant' - _logger.info('eskaera_shop: Product %s (id=%s) - Using list_price fallback (reason: %s). Price=%.2f', - product.name, product.id, reason, product.list_price) + reason = "no pricelist" if not pricelist else "no variant" + _logger.info( + "eskaera_shop: Product %s (id=%s) - Using list_price fallback (reason: %s). Price=%.2f", + product.name, + product.id, + reason, + product.list_price, + ) product_price_info[product.id] = { - 'price': product.list_price, - 'list_price': product.list_price, - 'has_discounted_price': False, - 'discount': 0.0, - 'tax_included': False, + "price": product.list_price, + "list_price": product.list_price, + "has_discounted_price": False, + "discount": 0.0, + "tax_included": False, } # Calculate available tags with product count (only show tags that are actually used and visible) @@ -522,28 +610,40 @@ class AplicoopWebsiteSale(WebsiteSale): for product in products: for tag in product.product_tag_ids: # Only include tags that are visible on ecommerce - is_visible = getattr(tag, 'visible_on_ecommerce', True) # Default to True if field doesn't exist + is_visible = getattr( + tag, "visible_on_ecommerce", True + ) # Default to True if field doesn't exist if not is_visible: continue - + if tag.id not in available_tags_dict: tag_color = tag.color if tag.color else None - _logger.info('Tag %s (id=%s): color=%s (type=%s)', tag.name, tag.id, tag_color, type(tag_color)) + _logger.info( + "Tag %s (id=%s): color=%s (type=%s)", + tag.name, + tag.id, + tag_color, + type(tag_color), + ) available_tags_dict[tag.id] = { - 'id': tag.id, - 'name': tag.name, - 'color': tag_color, # Use tag color (hex) or None for theme color - 'count': 0 + "id": tag.id, + "name": tag.name, + "color": tag_color, # Use tag color (hex) or None for theme color + "count": 0, } - available_tags_dict[tag.id]['count'] += 1 - + available_tags_dict[tag.id]["count"] += 1 + # Convert to sorted list of tags (sorted by name for consistent display) - available_tags = sorted(available_tags_dict.values(), key=lambda t: t['name']) - _logger.info('eskaera_shop: Found %d available tags for %d products', len(available_tags), len(products)) - _logger.info('eskaera_shop: available_tags = %s', available_tags) + available_tags = sorted(available_tags_dict.values(), key=lambda t: t["name"]) + _logger.info( + "eskaera_shop: Found %d available tags for %d products", + len(available_tags), + len(products), + ) + _logger.info("eskaera_shop: available_tags = %s", available_tags) # Manage session for separate cart per order - session_key = 'eskaera_{}'.format(order_id) + session_key = f"eskaera_{order_id}" cart = request.session.get(session_key, {}) # Get translated labels for JavaScript (same as checkout) @@ -555,91 +655,140 @@ class AplicoopWebsiteSale(WebsiteSale): for product in products: published_tags = self._filter_published_tags(product.product_tag_ids) filtered_products[product.id] = { - 'product': product, - 'published_tags': published_tags + "product": product, + "published_tags": published_tags, } - return request.render('website_sale_aplicoop.eskaera_shop', { - 'group_order': group_order, - 'products': products, - 'filtered_product_tags': filtered_products, - 'cart': cart, - 'available_categories': available_categories, - 'category_hierarchy': category_hierarchy, - 'available_tags': available_tags, - 'search_query': search_query, - 'selected_category': category_filter, - 'day_names': self._get_day_names(env=request.env), - 'product_supplier_info': product_supplier_info, - 'product_price_info': product_price_info, - 'labels': labels, - 'labels_json': json.dumps(labels, ensure_ascii=False), - }) + return request.render( + "website_sale_aplicoop.eskaera_shop", + { + "group_order": group_order, + "products": products, + "filtered_product_tags": filtered_products, + "cart": cart, + "available_categories": available_categories, + "category_hierarchy": category_hierarchy, + "available_tags": available_tags, + "search_query": search_query, + "selected_category": category_filter, + "day_names": self._get_day_names(env=request.env), + "product_supplier_info": product_supplier_info, + "product_price_info": product_price_info, + "labels": labels, + "labels_json": json.dumps(labels, ensure_ascii=False), + }, + ) - @http.route(['/eskaera/add-to-cart'], type='http', auth='user', - website=True, methods=['POST'], csrf=False) + @http.route( + ["/eskaera/add-to-cart"], + type="http", + auth="user", + website=True, + methods=["POST"], + csrf=False, + ) def add_to_eskaera_cart(self, **post): - '''Validate and confirm product addition to cart. + """Validate and confirm product addition to cart. The cart is managed in localStorage on the frontend. This endpoint only validates that the product exists in the order. - ''' + """ import json + try: # Get JSON data from the request body - data = json.loads(request.httprequest.data) if request.httprequest.data else {} + data = ( + json.loads(request.httprequest.data) if request.httprequest.data else {} + ) - order_id = int(data.get('order_id', 0)) - product_id = int(data.get('product_id', 0)) - quantity = float(data.get('quantity', 1)) + order_id = int(data.get("order_id", 0)) + product_id = int(data.get("product_id", 0)) + quantity = float(data.get("quantity", 1)) - group_order = request.env['group.order'].browse(order_id) - product = request.env['product.product'].browse(product_id) + group_order = request.env["group.order"].browse(order_id) + product = request.env["product.product"].browse(product_id) # Validate that the order exists and is open - if not group_order.exists() or group_order.state != 'open': - _logger.warning('add_to_eskaera_cart: Order %d not available (exists=%s, state=%s)', - order_id, group_order.exists(), group_order.state if group_order.exists() else 'N/A') + if not group_order.exists() or group_order.state != "open": + _logger.warning( + "add_to_eskaera_cart: Order %d not available (exists=%s, state=%s)", + order_id, + group_order.exists(), + group_order.state if group_order.exists() else "N/A", + ) return request.make_response( - json.dumps({'error': 'Order is not available'}), - [('Content-Type', 'application/json')]) + json.dumps({"error": "Order is not available"}), + [("Content-Type", "application/json")], + ) # Validate that the product is available in this order (use discovery logic) - available_products = group_order._get_products_for_group_order(group_order.id) + available_products = group_order._get_products_for_group_order( + group_order.id + ) if product not in available_products: - _logger.warning('add_to_eskaera_cart: Product %d not available in order %d', product_id, order_id) + _logger.warning( + "add_to_eskaera_cart: Product %d not available in order %d", + product_id, + order_id, + ) return request.make_response( - json.dumps({'error': 'Product not available in this order'}), - [('Content-Type', 'application/json')]) + json.dumps({"error": "Product not available in this order"}), + [("Content-Type", "application/json")], + ) # Validate quantity if quantity <= 0: return request.make_response( - json.dumps({'error': 'Quantity must be greater than 0'}), - [('Content-Type', 'application/json')]) + json.dumps({"error": "Quantity must be greater than 0"}), + [("Content-Type", "application/json")], + ) - _logger.info('add_to_eskaera_cart: Added product %d (qty=%f) to order %d', - product_id, quantity, order_id) + _logger.info( + "add_to_eskaera_cart: Added product %d (qty=%f) to order %d", + product_id, + quantity, + order_id, + ) # Get price with taxes using pricelist - _logger.info('add_to_eskaera_cart: Getting price for product %s (id=%s)', product.name, product_id) + _logger.info( + "add_to_eskaera_cart: Getting price for product %s (id=%s)", + product.name, + product_id, + ) try: pricelist = request.website._get_current_pricelist() - _logger.info('add_to_eskaera_cart: Pricelist: %s (id=%s)', - pricelist.name if pricelist else 'None', - pricelist.id if pricelist else 'None') + _logger.info( + "add_to_eskaera_cart: Pricelist: %s (id=%s)", + pricelist.name if pricelist else "None", + pricelist.id if pricelist else "None", + ) except Exception as e: - _logger.warning('add_to_eskaera_cart: Error getting pricelist: %s. Trying default.', str(e)) - pricelist = request.env['product.pricelist'].search([('active', '=', True)], limit=1) + _logger.warning( + "add_to_eskaera_cart: Error getting pricelist: %s. Trying default.", + str(e), + ) + pricelist = request.env["product.pricelist"].search( + [("active", "=", True)], limit=1 + ) if pricelist: - _logger.info('add_to_eskaera_cart: Default pricelist found: %s (id=%s)', pricelist.name, pricelist.id) - + _logger.info( + "add_to_eskaera_cart: Default pricelist found: %s (id=%s)", + pricelist.name, + pricelist.id, + ) + if not pricelist: - _logger.error('add_to_eskaera_cart: ERROR - No pricelist found! Using list_price for product %s', product.name) - - product_variant = product.product_variant_ids[0] if product.product_variant_ids else False - price_with_tax = product.list_price # Fallback - + _logger.error( + "add_to_eskaera_cart: ERROR - No pricelist found! Using list_price for product %s", + product.name, + ) + + product_variant = ( + product.product_variant_ids[0] if product.product_variant_ids else False + ) + base_price = product.list_price # Fallback + if product_variant and pricelist: try: # Use OCA _get_price method - more robust and complete @@ -648,322 +797,410 @@ class AplicoopWebsiteSale(WebsiteSale): pricelist=pricelist, fposition=request.website.fiscal_position_id, ) - price_with_tax = price_info.get('value', product.list_price) - _logger.info('add_to_eskaera_cart: Product %s - Calculated price with taxes: %.2f (original: %.2f, discount: %.1f%%)', - product.name, price_with_tax, price_info.get('original_value', 0), price_info.get('discount', 0)) + price_with_tax = price_info.get("value", product.list_price) + _logger.info( + "add_to_eskaera_cart: Product %s - Price: %.2f (original: %.2f, discount: %.1f%%)", + product.name, + price_with_tax, + price_info.get("original_value", 0), + price_info.get("discount", 0), + ) except Exception as e: - _logger.warning('add_to_eskaera_cart: Error getting price for product %s: %s. Using list_price=%.2f', - product.name, str(e), product.list_price) + _logger.warning( + "add_to_eskaera_cart: Error getting price for product %s: %s. Using list_price=%.2f", + product.name, + str(e), + product.list_price, + ) else: - reason = 'no pricelist' if not pricelist else 'no variant' - _logger.info('add_to_eskaera_cart: Product %s - Using list_price fallback (reason: %s). Price=%.2f', - product.name, reason, price_with_tax) + reason = "no pricelist" if not pricelist else "no variant" + _logger.info( + "add_to_eskaera_cart: Product %s - Using list_price fallback (reason: %s). Price=%.2f", + product.name, + reason, + price_with_tax, + ) response_data = { - 'success': True, - 'message': f'{_("%s added to cart") % product.name}', - 'product_id': product_id, - 'quantity': quantity, - 'price': price_with_tax, + "success": True, + "message": f'{_("%s added to cart") % product.name}', + "product_id": product_id, + "quantity": quantity, + "price": price_with_tax, } return request.make_response( - json.dumps(response_data), - [('Content-Type', 'application/json')]) + json.dumps(response_data), [("Content-Type", "application/json")] + ) except ValueError as e: - _logger.error('add_to_eskaera_cart: ValueError: %s', str(e)) + _logger.error("add_to_eskaera_cart: ValueError: %s", str(e)) return request.make_response( - json.dumps({'error': f'Invalid parameters: {str(e)}'}), - [('Content-Type', 'application/json')]) + json.dumps({"error": f"Invalid parameters: {str(e)}"}), + [("Content-Type", "application/json")], + ) except Exception as e: - _logger.error('add_to_eskaera_cart: Exception: %s', str(e), exc_info=True) - return request.make_response(json.dumps({'error': f'Error: {str(e)}'})) + _logger.error("add_to_eskaera_cart: Exception: %s", str(e), exc_info=True) + return request.make_response(json.dumps({"error": f"Error: {str(e)}"})) - @http.route(['/eskaera//checkout'], type='http', auth='user', - website=True) + @http.route( + ["/eskaera//checkout"], type="http", auth="user", website=True + ) def eskaera_checkout(self, order_id, **post): - '''Checkout page to close the cart for the order (eskaera).''' - group_order = request.env['group.order'].browse(order_id) + """Checkout page to close the cart for the order (eskaera).""" + group_order = request.env["group.order"].browse(order_id) if not group_order.exists(): - return request.redirect('/eskaera') + return request.redirect("/eskaera") # Verificar que el pedido está activo - if group_order.state != 'open': - return request.redirect('/eskaera') + if group_order.state != "open": + return request.redirect("/eskaera") # Los datos del carrito vienen desde localStorage en el frontend # Esta página solo muestra resumen y botón de confirmación - + # DEBUG: Log ALL delivery fields - _logger.warning('=== ESKAERA_CHECKOUT DELIVERY DEBUG ===') - _logger.warning('group_order.id: %s', group_order.id) - _logger.warning('group_order.name: %s', group_order.name) - _logger.warning('group_order.pickup_day: %s (type: %s)', group_order.pickup_day, type(group_order.pickup_day)) - _logger.warning('group_order.pickup_date: %s (type: %s)', group_order.pickup_date, type(group_order.pickup_date)) - _logger.warning('group_order.delivery_date: %s (type: %s)', group_order.delivery_date, type(group_order.delivery_date)) - _logger.warning('group_order.home_delivery: %s', group_order.home_delivery) - _logger.warning('group_order.delivery_notice: %s', group_order.delivery_notice) + _logger.warning("=== ESKAERA_CHECKOUT DELIVERY DEBUG ===") + _logger.warning("group_order.id: %s", group_order.id) + _logger.warning("group_order.name: %s", group_order.name) + _logger.warning( + "group_order.pickup_day: %s (type: %s)", + group_order.pickup_day, + type(group_order.pickup_day), + ) + _logger.warning( + "group_order.pickup_date: %s (type: %s)", + group_order.pickup_date, + type(group_order.pickup_date), + ) + _logger.warning( + "group_order.delivery_date: %s (type: %s)", + group_order.delivery_date, + type(group_order.delivery_date), + ) + _logger.warning("group_order.home_delivery: %s", group_order.home_delivery) + _logger.warning("group_order.delivery_notice: %s", group_order.delivery_notice) if group_order.pickup_date: - _logger.warning('pickup_date formatted: %s', group_order.pickup_date.strftime('%d/%m/%Y')) - _logger.warning('========================================') - + _logger.warning( + "pickup_date formatted: %s", + group_order.pickup_date.strftime("%d/%m/%Y"), + ) + _logger.warning("========================================") + # Get delivery product ID and name (translated to user's language) - delivery_product = request.env.ref('website_sale_aplicoop.product_home_delivery', raise_if_not_found=False) + delivery_product = request.env.ref( + "website_sale_aplicoop.product_home_delivery", raise_if_not_found=False + ) delivery_product_id = delivery_product.id if delivery_product else None # Get translated product name based on current language if delivery_product: - delivery_product_translated = delivery_product.with_context(lang=request.env.lang) + delivery_product_translated = delivery_product.with_context( + lang=request.env.lang + ) delivery_product_name = delivery_product_translated.name else: - delivery_product_name = 'Home Delivery' - + delivery_product_name = "Home Delivery" + # Get all translated labels for JavaScript (same as shop page) # This includes all 37 labels: modal labels, confirmation, notifications, cart buttons, etc. labels = self.get_checkout_labels() - + # Convert to JSON string for safe embedding in script tag labels_json = json.dumps(labels, ensure_ascii=False) - + # Prepare template context with explicit debug info template_context = { - 'group_order': group_order, - 'day_names': self._get_day_names(env=request.env), - 'delivery_product_id': delivery_product_id, - 'delivery_product_name': delivery_product_name, # Auto-translated to user's language - 'delivery_product_price': delivery_product.list_price if delivery_product else 5.74, - 'labels': labels, - 'labels_json': labels_json, + "group_order": group_order, + "day_names": self._get_day_names(env=request.env), + "delivery_product_id": delivery_product_id, + "delivery_product_name": delivery_product_name, # Auto-translated to user's language + "delivery_product_price": ( + delivery_product.list_price if delivery_product else 5.74 + ), + "labels": labels, + "labels_json": labels_json, } - - _logger.warning('Template context keys: %s', list(template_context.keys())) - - return request.render('website_sale_aplicoop.eskaera_checkout', template_context) - @http.route(['/eskaera/save-cart'], type='http', auth='user', website=True, - methods=['POST'], csrf=False) + _logger.warning("Template context keys: %s", list(template_context.keys())) + + return request.render( + "website_sale_aplicoop.eskaera_checkout", template_context + ) + + @http.route( + ["/eskaera/save-cart"], + type="http", + auth="user", + website=True, + methods=["POST"], + csrf=False, + ) def save_cart_draft(self, **post): - '''Save cart items as a draft sale.order with pickup date.''' + """Save cart items as a draft sale.order with pickup date.""" import json try: - _logger.warning('=== SAVE_CART_DRAFT CALLED ===') - + _logger.warning("=== SAVE_CART_DRAFT CALLED ===") + if not request.httprequest.data: return request.make_response( - json.dumps({'error': 'No data provided'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No data provided"}), + [("Content-Type", "application/json")], + status=400, + ) # Decode JSON try: raw_data = request.httprequest.data if isinstance(raw_data, bytes): - raw_data = raw_data.decode('utf-8') + raw_data = raw_data.decode("utf-8") data = json.loads(raw_data) except Exception as e: - _logger.error('Error decoding JSON: %s', str(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) + json.dumps({"error": f"Invalid JSON: {str(e)}"}), + [("Content-Type", "application/json")], + status=400, + ) - _logger.info('save_cart_draft data received: %s', data) + _logger.info("save_cart_draft data received: %s", data) # Validate order_id - order_id = data.get('order_id') + 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) + 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) + 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'].browse(order_id) + group_order = request.env["group.order"].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) + json.dumps({"error": f"Order {order_id} not found"}), + [("Content-Type", "application/json")], + status=400, + ) 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) + json.dumps({"error": "User has no associated partner"}), + [("Content-Type", "application/json")], + status=400, + ) # Get cart items and pickup date - items = data.get('items', []) - pickup_date = data.get('pickup_date') # Date from group_order - is_delivery = data.get('is_delivery', False) # If home delivery selected - + items = data.get("items", []) + pickup_date = data.get("pickup_date") # Date from group_order + is_delivery = data.get("is_delivery", False) # If home delivery selected + if not items: return request.make_response( - json.dumps({'error': 'No items in cart'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No items in cart"}), + [("Content-Type", "application/json")], + status=400, + ) - _logger.info('Creating draft sale.order with %d items for partner %d', len(items), current_user.partner_id.id) + _logger.info( + "Creating draft sale.order with %d items for partner %d", + len(items), + current_user.partner_id.id, + ) # Create sales.order lines from items sale_order_lines = [] 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_id = int(item.get("product_id")) + quantity = float(item.get("quantity", 1)) + price = float(item.get("product_price", 0)) - product = request.env['product.product'].browse(product_id) + product = request.env["product.product"].browse(product_id) if not product.exists(): - _logger.warning('save_cart_draft: Product %d does not exist', product_id) + _logger.warning( + "save_cart_draft: Product %d does not exist", product_id + ) continue - line = (0, 0, { - 'product_id': product_id, - 'product_uom_qty': quantity, - 'price_unit': price, - }) + line = ( + 0, + 0, + { + "product_id": product_id, + "product_uom_qty": quantity, + "price_unit": price, + }, + ) sale_order_lines.append(line) except Exception as e: - _logger.error('Error processing item %s: %s', item, str(e)) + _logger.error("Error processing item %s: %s", item, str(e)) if not sale_order_lines: return request.make_response( - json.dumps({'error': 'No valid items to save'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No valid items to save"}), + [("Content-Type", "application/json")], + status=400, + ) # Create order values dict order_vals = { - 'partner_id': current_user.partner_id.id, - 'order_line': sale_order_lines, - 'state': 'draft', - 'group_order_id': order_id, # Link to the group.order + "partner_id": current_user.partner_id.id, + "order_line": sale_order_lines, + "state": "draft", + "group_order_id": order_id, # Link to the group.order } - + # Propagate fields from group order (ensure they exist) if group_order.pickup_day: - order_vals['pickup_day'] = group_order.pickup_day - _logger.info('Set pickup_day: %s', group_order.pickup_day) - + order_vals["pickup_day"] = group_order.pickup_day + _logger.info("Set pickup_day: %s", group_order.pickup_day) + if group_order.pickup_date: - order_vals['pickup_date'] = group_order.pickup_date - _logger.info('Set pickup_date: %s', group_order.pickup_date) - + order_vals["pickup_date"] = group_order.pickup_date + _logger.info("Set pickup_date: %s", group_order.pickup_date) + if group_order.home_delivery: - order_vals['home_delivery'] = group_order.home_delivery - _logger.info('Set home_delivery: %s', group_order.home_delivery) - + order_vals["home_delivery"] = group_order.home_delivery + _logger.info("Set home_delivery: %s", group_order.home_delivery) + # Add commitment date (pickup/delivery date) if provided if pickup_date: - order_vals['commitment_date'] = pickup_date + order_vals["commitment_date"] = pickup_date elif group_order.pickup_date: # Fallback to group order pickup date - order_vals['commitment_date'] = group_order.pickup_date - _logger.info('Set commitment_date from group_order.pickup_date: %s', group_order.pickup_date) + order_vals["commitment_date"] = group_order.pickup_date + _logger.info( + "Set commitment_date from group_order.pickup_date: %s", + group_order.pickup_date, + ) - _logger.info('Creating sale.order with values: %s', order_vals) + _logger.info("Creating sale.order with values: %s", order_vals) # Create the sale.order - sale_order = request.env['sale.order'].create(order_vals) - + sale_order = request.env["sale.order"].create(order_vals) + # Ensure the order has a name (draft orders may not have one yet) - if not sale_order.name or sale_order.name == 'New': + if not sale_order.name or sale_order.name == "New": # Force sequence generation for draft order sale_order._onchange_partner_id() # This may trigger name generation - if not sale_order.name or sale_order.name == 'New': + if not sale_order.name or sale_order.name == "New": # If still no name, use a temporary one - sale_order.name = 'DRAFT-%s' % sale_order.id + sale_order.name = "DRAFT-%s" % sale_order.id - _logger.info('Draft sale.order created: %d (name: %s) for partner %d with group_order_id=%s, pickup_day=%s, pickup_date=%s', - sale_order.id, sale_order.name, 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) + _logger.info( + "Draft sale.order created: %d (name: %s) for partner %d with group_order_id=%s, pickup_day=%s, pickup_date=%s", + sale_order.id, + sale_order.name, + 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, + ) return request.make_response( - json.dumps({ - 'success': True, - 'message': _('Cart saved as draft'), - 'sale_order_id': sale_order.id, - }), - [('Content-Type', 'application/json')]) + json.dumps( + { + "success": True, + "message": _("Cart saved as draft"), + "sale_order_id": sale_order.id, + } + ), + [("Content-Type", "application/json")], + ) except Exception as e: import traceback - _logger.error('save_cart_draft: Unexpected error: %s', str(e)) + + _logger.error("save_cart_draft: Unexpected error: %s", str(e)) _logger.error(traceback.format_exc()) return request.make_response( - json.dumps({'error': str(e)}), - [('Content-Type', 'application/json')], - status=500) + json.dumps({"error": str(e)}), + [("Content-Type", "application/json")], + status=500, + ) - @http.route(['/eskaera/load-draft'], type='http', auth='user', website=True, - methods=['POST'], csrf=False) + @http.route( + ["/eskaera/load-draft"], + type="http", + auth="user", + website=True, + methods=["POST"], + csrf=False, + ) def load_draft_cart(self, **post): - '''Load items from the most recent draft sale.order for this week.''' + """Load items from the most recent draft sale.order for this week.""" import json - from datetime import datetime, timedelta + from datetime import datetime + from datetime import timedelta try: - _logger.warning('=== LOAD_DRAFT_CART CALLED ===') - + _logger.warning("=== LOAD_DRAFT_CART CALLED ===") + if not request.httprequest.data: return request.make_response( - json.dumps({'error': 'No data provided'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No data provided"}), + [("Content-Type", "application/json")], + status=400, + ) # Decode JSON try: raw_data = request.httprequest.data if isinstance(raw_data, bytes): - raw_data = raw_data.decode('utf-8') + raw_data = raw_data.decode("utf-8") data = json.loads(raw_data) except Exception as e: - _logger.error('Error decoding JSON: %s', str(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) + json.dumps({"error": f"Invalid JSON: {str(e)}"}), + [("Content-Type", "application/json")], + status=400, + ) - order_id = data.get('order_id') + 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) + 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) + json.dumps({"error": f"Invalid order_id format: {order_id}"}), + [("Content-Type", "application/json")], + status=400, + ) - group_order = request.env['group.order'].browse(order_id) + group_order = request.env["group.order"].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) + json.dumps({"error": f"Order {order_id} not found"}), + [("Content-Type", "application/json")], + status=400, + ) 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) + json.dumps({"error": "User has no associated partner"}), + [("Content-Type", "application/json")], + status=400, + ) # Find the most recent draft sale.order for this partner from this week # Get start of current week (Monday) @@ -971,440 +1208,582 @@ class AplicoopWebsiteSale(WebsiteSale): start_of_week = today - timedelta(days=today.weekday()) end_of_week = start_of_week + timedelta(days=6) - _logger.info('Searching for draft orders between %s and %s for partner %d and group_order %d', - start_of_week, end_of_week, current_user.partner_id.id, order_id) + _logger.info( + "Searching for draft orders between %s and %s for partner %d and group_order %d", + start_of_week, + end_of_week, + current_user.partner_id.id, + order_id, + ) # Debug: Check all draft orders for this user - all_drafts = request.env['sale.order'].search([ - ('partner_id', '=', current_user.partner_id.id), - ('state', '=', 'draft'), - ]) - _logger.info('DEBUG: Found %d total draft orders for partner %d:', - len(all_drafts), current_user.partner_id.id) + all_drafts = request.env["sale.order"].search( + [ + ("partner_id", "=", current_user.partner_id.id), + ("state", "=", "draft"), + ] + ) + _logger.info( + "DEBUG: Found %d total draft orders for partner %d:", + len(all_drafts), + current_user.partner_id.id, + ) for draft in all_drafts: - _logger.info(' - Order ID: %d, group_order_id: %s, create_date: %s', - draft.id, draft.group_order_id.id if draft.group_order_id else 'None', - draft.create_date) + _logger.info( + " - Order ID: %d, group_order_id: %s, create_date: %s", + draft.id, + draft.group_order_id.id if draft.group_order_id else "None", + draft.create_date, + ) - draft_orders = request.env['sale.order'].search([ - ('partner_id', '=', current_user.partner_id.id), - ('group_order_id', '=', order_id), # Filter by group.order - ('state', '=', 'draft'), - ('create_date', '>=', f'{start_of_week} 00:00:00'), - ('create_date', '<=', f'{end_of_week} 23:59:59'), - ], order='create_date desc', limit=1) + draft_orders = request.env["sale.order"].search( + [ + ("partner_id", "=", current_user.partner_id.id), + ("group_order_id", "=", order_id), # Filter by group.order + ("state", "=", "draft"), + ("create_date", ">=", f"{start_of_week} 00:00:00"), + ("create_date", "<=", f"{end_of_week} 23:59:59"), + ], + order="create_date desc", + limit=1, + ) - _logger.info('DEBUG: Found %d matching draft orders with filters', len(draft_orders)) + _logger.info( + "DEBUG: Found %d matching draft orders with filters", len(draft_orders) + ) if not draft_orders: - error_msg = request.env._('No draft orders found for this week') + error_msg = request.env._("No draft orders found for this week") return request.make_response( - json.dumps({'error': error_msg}), - [('Content-Type', 'application/json')], - status=404) + json.dumps({"error": error_msg}), + [("Content-Type", "application/json")], + status=404, + ) draft_order = draft_orders[0] - + # Extract items from the draft order items = [] for line in draft_order.order_line: - items.append({ - 'product_id': line.product_id.id, - 'product_name': line.product_id.name, - 'quantity': line.product_uom_qty, - 'product_price': line.price_unit, - }) + items.append( + { + "product_id": line.product_id.id, + "product_name": line.product_id.name, + "quantity": line.product_uom_qty, + "product_price": line.price_unit, + } + ) - _logger.info('Loaded %d items from draft order %d', len(items), draft_order.id) + _logger.info( + "Loaded %d items from draft order %d", len(items), draft_order.id + ) return request.make_response( - json.dumps({ - 'success': True, - 'message': _('Draft order loaded'), - 'items': items, - 'sale_order_id': draft_order.id, - 'group_order_id': draft_order.group_order_id.id, - 'group_order_name': draft_order.group_order_id.name, - 'pickup_day': draft_order.pickup_day, - 'pickup_date': str(draft_order.pickup_date) if draft_order.pickup_date else None, - 'home_delivery': draft_order.home_delivery, - }), - [('Content-Type', 'application/json')]) + json.dumps( + { + "success": True, + "message": _("Draft order loaded"), + "items": items, + "sale_order_id": draft_order.id, + "group_order_id": draft_order.group_order_id.id, + "group_order_name": draft_order.group_order_id.name, + "pickup_day": draft_order.pickup_day, + "pickup_date": ( + str(draft_order.pickup_date) + if draft_order.pickup_date + else None + ), + "home_delivery": draft_order.home_delivery, + } + ), + [("Content-Type", "application/json")], + ) except Exception as e: import traceback - _logger.error('load_draft_cart: Unexpected error: %s', str(e)) + + _logger.error("load_draft_cart: Unexpected error: %s", str(e)) _logger.error(traceback.format_exc()) return request.make_response( - json.dumps({'error': str(e)}), - [('Content-Type', 'application/json')], - status=500) + json.dumps({"error": str(e)}), + [("Content-Type", "application/json")], + status=500, + ) - @http.route(['/eskaera/save-order'], type='http', auth='user', website=True, - methods=['POST'], csrf=False) + @http.route( + ["/eskaera/save-order"], + type="http", + auth="user", + website=True, + methods=["POST"], + csrf=False, + ) def save_eskaera_draft(self, **post): - '''Save order as draft (without confirming). + """Save order as draft (without confirming). Creates a sale.order from the cart items with state='draft'. If a draft already exists for this group order, prompt user for merge/replace. - ''' + """ import json try: - _logger.warning('=== SAVE_ESKAERA_DRAFT CALLED ===') - + _logger.warning("=== SAVE_ESKAERA_DRAFT CALLED ===") + if not request.httprequest.data: - _logger.warning('save_eskaera_draft: No request data provided') + _logger.warning("save_eskaera_draft: No request data provided") return request.make_response( - json.dumps({'error': 'No data provided'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No data provided"}), + [("Content-Type", "application/json")], + status=400, + ) # Decode JSON try: raw_data = request.httprequest.data if isinstance(raw_data, bytes): - raw_data = raw_data.decode('utf-8') + raw_data = raw_data.decode("utf-8") data = json.loads(raw_data) except Exception as e: - _logger.error('Error decoding JSON: %s', str(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) + json.dumps({"error": f"Invalid JSON: {str(e)}"}), + [("Content-Type", "application/json")], + status=400, + ) - _logger.info('save_eskaera_draft data received: %s', data) + _logger.info("save_eskaera_draft data received: %s", data) # Validate order_id - order_id = data.get('order_id') + order_id = data.get("order_id") if not order_id: - _logger.warning('save_eskaera_draft: order_id missing') + _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) + 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) + _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) + 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'].browse(order_id) + group_order = request.env["group.order"].browse(order_id) if not group_order.exists(): - _logger.warning('save_eskaera_draft: Order %d not found', order_id) + _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) + json.dumps({"error": f"Order {order_id} not found"}), + [("Content-Type", "application/json")], + status=400, + ) 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) + _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) + json.dumps({"error": "User has no associated partner"}), + [("Content-Type", "application/json")], + status=400, + ) # 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 - + 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('save_eskaera_draft: No items in cart for user %d in order %d', - current_user.id, order_id) + _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) + json.dumps({"error": "No items in cart"}), + [("Content-Type", "application/json")], + status=400, + ) # Check if a draft already exists for this group order and user - existing_drafts = request.env['sale.order'].search([ - ('group_order_id', '=', order_id), - ('partner_id', '=', current_user.partner_id.id), - ('state', '=', 'draft'), - ]) + existing_drafts = request.env["sale.order"].search( + [ + ("group_order_id", "=", order_id), + ("partner_id", "=", current_user.partner_id.id), + ("state", "=", "draft"), + ] + ) # 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': _('A draft already exists for this week.'), - }), - [('Content-Type', 'application/json')]) + 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 + ] - _logger.info('Creating draft sale.order with %d items for partner %d', len(items), current_user.partner_id.id) + 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": _("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), + current_user.partner_id.id, + ) # Create sales.order lines from items sale_order_lines = [] 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_id = int(item.get("product_id")) + quantity = float(item.get("quantity", 1)) + price = float(item.get("product_price", 0)) - product = request.env['product.product'].browse(product_id) + product = request.env["product.product"].browse(product_id) if not product.exists(): - _logger.warning('save_eskaera_draft: Product %d does not exist', product_id) + _logger.warning( + "save_eskaera_draft: Product %d does not exist", product_id + ) continue # Calculate subtotal subtotal = quantity * price - _logger.info('Item: product_id=%d, quantity=%.2f, price=%.2f, subtotal=%.2f', - product_id, quantity, price, subtotal) + _logger.info( + "Item: product_id=%d, quantity=%.2f, price=%.2f, subtotal=%.2f", + product_id, + quantity, + price, + subtotal, + ) # Create order line as a tuple for create() operation - line = (0, 0, { - 'product_id': product_id, - 'product_uom_qty': quantity, - 'price_unit': price, - }) + line = ( + 0, + 0, + { + "product_id": product_id, + "product_uom_qty": quantity, + "price_unit": price, + }, + ) sale_order_lines.append(line) except Exception as e: - _logger.error('Error processing item %s: %s', item, str(e)) + _logger.error("Error processing item %s: %s", item, str(e)) if not sale_order_lines: return request.make_response( - json.dumps({'error': 'No valid items to save'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No valid items to save"}), + [("Content-Type", "application/json")], + status=400, + ) # Handle merge vs replace action - if merge_action == 'merge' and existing_draft_id: + if merge_action == "merge" and existing_draft_id: # Merge: Add items to existing draft - existing_draft = request.env['sale.order'].browse(int(existing_draft_id)) + existing_draft = request.env["sale.order"].browse( + int(existing_draft_id) + ) if existing_draft.exists(): # Merge items: update quantities if product exists, add if new 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'] - + 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"] + # Find if product already exists in draft existing_line = existing_draft.order_line.filtered( lambda l: l.product_id.id == product_id ) - + if existing_line: # Update quantity (add to existing) - existing_line.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) + existing_line.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: # Add new line to existing draft - existing_draft.order_line.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) - + existing_draft.order_line.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, + ) + sale_order = existing_draft merge_success = True - - elif merge_action == 'replace' and existing_draft_id and existing_drafts: + + elif merge_action == "replace" and existing_draft_id and existing_drafts: # Replace: Delete old draft and create new one existing_drafts.unlink() - _logger.info('Deleted existing draft %d', existing_draft_id) - + _logger.info("Deleted existing draft %d", existing_draft_id) + # Create new draft with current items - sale_order = request.env['sale.order'].create({ - 'partner_id': current_user.partner_id.id, - 'order_line': sale_order_lines, - 'state': 'draft', # Explicitly set to draft - 'group_order_id': order_id, # Link to the group.order - 'pickup_day': group_order.pickup_day, # Propagate from group order - 'pickup_date': group_order.pickup_date, # Propagate from group order - 'home_delivery': group_order.home_delivery, # Propagate from group order - }) + sale_order = request.env["sale.order"].create( + { + "partner_id": current_user.partner_id.id, + "order_line": sale_order_lines, + "state": "draft", # Explicitly set to draft + "group_order_id": order_id, # Link to the group.order + "pickup_day": group_order.pickup_day, # Propagate from group order + "pickup_date": group_order.pickup_date, # Propagate from group order + "home_delivery": group_order.home_delivery, # Propagate from group order + } + ) merge_success = False - + else: # No existing draft, create new one - sale_order = request.env['sale.order'].create({ - 'partner_id': current_user.partner_id.id, - 'order_line': sale_order_lines, - 'state': 'draft', # Explicitly set to draft - 'group_order_id': order_id, # Link to the group.order - 'pickup_day': group_order.pickup_day, # Propagate from group order - 'pickup_date': group_order.pickup_date, # Propagate from group order - 'home_delivery': group_order.home_delivery, # Propagate from group order - }) + sale_order = request.env["sale.order"].create( + { + "partner_id": current_user.partner_id.id, + "order_line": sale_order_lines, + "state": "draft", # Explicitly set to draft + "group_order_id": order_id, # Link to the group.order + "pickup_day": group_order.pickup_day, # Propagate from group order + "pickup_date": group_order.pickup_date, # Propagate from group order + "home_delivery": group_order.home_delivery, # Propagate from group order + } + ) merge_success = False - _logger.info('Draft sale.order created/updated: %d for partner %d with group_order_id=%s, pickup_day=%s, pickup_date=%s, home_delivery=%s', - sale_order.id, current_user.partner_id.id, - sale_order.group_order_id.id if sale_order.group_order_id else None, - sale_order.pickup_day, sale_order.pickup_date, sale_order.home_delivery) + _logger.info( + "Draft sale.order created/updated: %d for partner %d with group_order_id=%s, pickup_day=%s, pickup_date=%s, home_delivery=%s", + sale_order.id, + current_user.partner_id.id, + sale_order.group_order_id.id if sale_order.group_order_id else None, + sale_order.pickup_day, + sale_order.pickup_date, + sale_order.home_delivery, + ) return request.make_response( - json.dumps({ - 'success': True, - 'message': _('Merged with existing draft') if merge_success else _('Order saved as draft'), - 'sale_order_id': sale_order.id, - 'merged': merge_success, - }), - [('Content-Type', 'application/json')]) + json.dumps( + { + "success": True, + "message": ( + _("Merged with existing draft") + if merge_success + else _("Order saved as draft") + ), + "sale_order_id": sale_order.id, + "merged": merge_success, + } + ), + [("Content-Type", "application/json")], + ) except Exception as e: import traceback - _logger.error('save_eskaera_draft: Unexpected error: %s', str(e)) + + _logger.error("save_eskaera_draft: Unexpected error: %s", str(e)) _logger.error(traceback.format_exc()) return request.make_response( - json.dumps({'error': str(e)}), - [('Content-Type', 'application/json')], - status=500) + json.dumps({"error": str(e)}), + [("Content-Type", "application/json")], + status=500, + ) - @http.route(['/eskaera/confirm'], type='http', auth='user', website=True, - methods=['POST'], csrf=False) + @http.route( + ["/eskaera/confirm"], + type="http", + auth="user", + website=True, + methods=["POST"], + csrf=False, + ) def confirm_eskaera(self, **post): - '''Confirm order and create sale.order from cart (localStorage). + """Confirm order and create sale.order from cart (localStorage). Items come from the cart stored in the frontend localStorage. - ''' + """ import json try: # Initial log for debug - _logger.warning('=== CONFIRM_ESKAERA CALLED ===') - _logger.warning('Request data: %s', request.httprequest.data[:200] if request.httprequest.data else 'EMPTY') + _logger.warning("=== CONFIRM_ESKAERA CALLED ===") + _logger.warning( + "Request data: %s", + request.httprequest.data[:200] if request.httprequest.data else "EMPTY", + ) # Get JSON data from the request body if not request.httprequest.data: - _logger.warning('confirm_eskaera: No request data provided') + _logger.warning("confirm_eskaera: No request data provided") return request.make_response( - json.dumps({'error': 'No data provided'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No data provided"}), + [("Content-Type", "application/json")], + status=400, + ) # Decode JSON try: raw_data = request.httprequest.data if isinstance(raw_data, bytes): - raw_data = raw_data.decode('utf-8') + raw_data = raw_data.decode("utf-8") data = json.loads(raw_data) except Exception as e: - _logger.error('Error decoding JSON: %s', str(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) + json.dumps({"error": f"Invalid JSON: {str(e)}"}), + [("Content-Type", "application/json")], + status=400, + ) - _logger.info('confirm_eskaera data received: %s', data) + _logger.info("confirm_eskaera data received: %s", data) # Validate order_id - order_id = data.get('order_id') + order_id = data.get("order_id") if not order_id: - _logger.warning('confirm_eskaera: order_id missing') + _logger.warning("confirm_eskaera: order_id missing") return request.make_response( - json.dumps({'error': 'order_id is required'}), - [('Content-Type', 'application/json')], - status=400) + 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) as e: - _logger.warning('confirm_eskaera: Invalid order_id: %s', order_id) + _logger.warning("confirm_eskaera: 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) + json.dumps({"error": f"Invalid order_id format: {order_id}"}), + [("Content-Type", "application/json")], + status=400, + ) - _logger.info('order_id: %d', order_id) + _logger.info("order_id: %d", order_id) # Verify that the order exists - group_order = request.env['group.order'].browse(order_id) + group_order = request.env["group.order"].browse(order_id) if not group_order.exists(): - _logger.warning('confirm_eskaera: Order %d not found', order_id) + _logger.warning("confirm_eskaera: 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) + json.dumps({"error": f"Order {order_id} not found"}), + [("Content-Type", "application/json")], + status=400, + ) # Verify that the order is open - if group_order.state != 'open': - _logger.warning('confirm_eskaera: Order %d is not open (state: %s)', order_id, group_order.state) + if group_order.state != "open": + _logger.warning( + "confirm_eskaera: Order %d is not open (state: %s)", + order_id, + group_order.state, + ) return request.make_response( - json.dumps({'error': f'Order is {group_order.state}'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": f"Order is {group_order.state}"}), + [("Content-Type", "application/json")], + status=400, + ) current_user = request.env.user - _logger.info('Current user: %d', current_user.id) + _logger.info("Current user: %d", current_user.id) # Validate that the user has a partner_id if not current_user.partner_id: - _logger.error('confirm_eskaera: User %d has no partner_id', current_user.id) + _logger.error( + "confirm_eskaera: 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) + json.dumps({"error": "User has no associated partner"}), + [("Content-Type", "application/json")], + status=400, + ) # Get cart items and delivery status - items = data.get('items', []) - is_delivery = data.get('is_delivery', False) + items = data.get("items", []) + is_delivery = data.get("is_delivery", False) if not items: - _logger.warning('confirm_eskaera: No items in cart for user %d in order %d', - current_user.id, order_id) + _logger.warning( + "confirm_eskaera: 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) + json.dumps({"error": "No items in cart"}), + [("Content-Type", "application/json")], + status=400, + ) # First, check if there's already a draft sale.order for this user in this group order - existing_order = request.env['sale.order'].search([ - ('partner_id', '=', current_user.partner_id.id), - ('group_order_id', '=', group_order.id), - ('state', '=', 'draft') - ], limit=1) + existing_order = request.env["sale.order"].search( + [ + ("partner_id", "=", current_user.partner_id.id), + ("group_order_id", "=", group_order.id), + ("state", "=", "draft"), + ], + limit=1, + ) if existing_order: - _logger.info('Found existing draft order: %d, updating instead of creating new', existing_order.id) + _logger.info( + "Found existing draft order: %d, updating instead of creating new", + existing_order.id, + ) # Delete existing lines and create new ones existing_order.order_line.unlink() sale_order = existing_order else: - _logger.info('No existing draft order found, will create new sale.order') + _logger.info( + "No existing draft order found, will create new sale.order" + ) sale_order = None # Create sales.order lines from items sale_order_lines = [] 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_id = int(item.get("product_id")) + quantity = float(item.get("quantity", 1)) + price = float(item.get("product_price", 0)) - product = request.env['product.product'].browse(product_id) + product = request.env["product.product"].browse(product_id) if not product.exists(): - _logger.warning('confirm_eskaera: Product %d does not exist', product_id) + _logger.warning( + "confirm_eskaera: Product %d does not exist", product_id + ) continue # Get product name in user's language context @@ -1412,23 +1791,26 @@ class AplicoopWebsiteSale(WebsiteSale): product_name = product_in_lang.name line_data = { - 'product_id': product_id, - 'product_uom_qty': quantity, - 'price_unit': price or product.list_price, - 'name': product_name, # Force the translated product name + "product_id": product_id, + "product_uom_qty": quantity, + "price_unit": price or product.list_price, + "name": product_name, # Force the translated product name } - _logger.info('Adding sale order line: %s', line_data) + _logger.info("Adding sale order line: %s", line_data) sale_order_lines.append((0, 0, line_data)) except (ValueError, TypeError) as e: - _logger.warning('confirm_eskaera: Error processing item %s: %s', item, str(e)) + _logger.warning( + "confirm_eskaera: Error processing item %s: %s", item, str(e) + ) continue if not sale_order_lines: - _logger.warning('confirm_eskaera: No valid items for sale.order') + _logger.warning("confirm_eskaera: No valid items for sale.order") return request.make_response( - json.dumps({'error': 'No valid items in cart'}), - [('Content-Type', 'application/json')], - status=400) + json.dumps({"error": "No valid items in cart"}), + [("Content-Type", "application/json")], + status=400, + ) # Get pickup date and delivery info from group order # If delivery, use delivery_date; otherwise use pickup_date @@ -1442,7 +1824,11 @@ class AplicoopWebsiteSale(WebsiteSale): try: if sale_order: # Update existing order with new lines - _logger.info('Updating existing sale.order %d with %d items', sale_order.id, len(sale_order_lines)) + _logger.info( + "Updating existing sale.order %d with %d items", + sale_order.id, + len(sale_order_lines), + ) sale_order.order_line = sale_order_lines # Ensure group_order_id is set and propagate group order fields if not sale_order.group_order_id: @@ -1453,29 +1839,38 @@ class AplicoopWebsiteSale(WebsiteSale): sale_order.home_delivery = is_delivery if commitment_date: sale_order.commitment_date = commitment_date - _logger.info('Updated sale.order %d: commitment_date=%s, home_delivery=%s', - sale_order.id, commitment_date, is_delivery) + _logger.info( + "Updated sale.order %d: commitment_date=%s, home_delivery=%s", + sale_order.id, + commitment_date, + is_delivery, + ) else: # Create new order order_vals = { - 'partner_id': current_user.partner_id.id, - 'order_line': sale_order_lines, - 'group_order_id': group_order.id, - 'pickup_day': group_order.pickup_day, - 'pickup_date': group_order.pickup_date, - 'home_delivery': is_delivery, + "partner_id": current_user.partner_id.id, + "order_line": sale_order_lines, + "group_order_id": group_order.id, + "pickup_day": group_order.pickup_day, + "pickup_date": group_order.pickup_date, + "home_delivery": is_delivery, } - + # Add commitment date (pickup/delivery date) if available if commitment_date: - order_vals['commitment_date'] = commitment_date - - sale_order = request.env['sale.order'].create(order_vals) - _logger.info('sale.order created successfully: %d with group_order_id=%d, pickup_day=%s, home_delivery=%s', - sale_order.id, group_order.id, group_order.pickup_day, group_order.home_delivery) + order_vals["commitment_date"] = commitment_date + + sale_order = request.env["sale.order"].create(order_vals) + _logger.info( + "sale.order created successfully: %d with group_order_id=%d, pickup_day=%s, home_delivery=%s", + sale_order.id, + group_order.id, + group_order.pickup_day, + group_order.home_delivery, + ) except Exception as e: - _logger.error('Error creating/updating sale.order: %s', str(e)) - _logger.error('sale_order_lines: %s', sale_order_lines) + _logger.error("Error creating/updating sale.order: %s", str(e)) + _logger.error("sale_order_lines: %s", sale_order_lines) raise # Build a localized confirmation message on the server so the @@ -1487,29 +1882,31 @@ class AplicoopWebsiteSale(WebsiteSale): except Exception: pickup_day_index = None - base_message = (_('Thank you! Your order has been confirmed.')) - order_reference_label = (_('Order reference')) - pickup_label = (_('Pickup day')) - delivery_label = (_('Delivery date')) - pickup_day_name = '' - pickup_date_str = '' - + base_message = _("Thank you! Your order has been confirmed.") + order_reference_label = _("Order reference") + pickup_label = _("Pickup day") + delivery_label = _("Delivery date") + pickup_day_name = "" + pickup_date_str = "" + # Add order reference to message if sale_order.name: - base_message = f"{base_message}\n\n{order_reference_label}: {sale_order.name}" + base_message = ( + f"{base_message}\n\n{order_reference_label}: {sale_order.name}" + ) 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 = '' - + pickup_day_name = "" + # Add pickup/delivery date in numeric format if group_order.pickup_date: if is_delivery: # For delivery, use delivery_date (already computed as pickup_date + 1) if group_order.delivery_date: - pickup_date_str = group_order.delivery_date.strftime('%d/%m/%Y') + 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: @@ -1518,12 +1915,12 @@ class AplicoopWebsiteSale(WebsiteSale): next_day_index = (pickup_day_index + 1) % 7 pickup_day_name = day_names[next_day_index] except Exception: - pickup_day_name = '' + pickup_day_name = "" else: - pickup_date_str = group_order.pickup_date.strftime('%d/%m/%Y') + 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') + pickup_date_str = group_order.pickup_date.strftime("%d/%m/%Y") # Build final message with correct label and date based on delivery or pickup message = base_message @@ -1536,77 +1933,94 @@ class AplicoopWebsiteSale(WebsiteSale): message = f"{message}\n\n\n{label_to_use}: {pickup_date_str}" response_data = { - 'success': True, - 'message': message, - 'sale_order_id': sale_order.id, - 'redirect_url': sale_order.get_portal_url(), - 'group_order_name': group_order.name, - 'pickup_day': pickup_day_name, - 'pickup_date': pickup_date_str, - 'pickup_day_index': pickup_day_index, + "success": True, + "message": message, + "sale_order_id": sale_order.id, + "redirect_url": sale_order.get_portal_url(), + "group_order_name": group_order.name, + "pickup_day": pickup_day_name, + "pickup_date": pickup_date_str, + "pickup_day_index": pickup_day_index, } # Log language and final message to debug translation issues try: - _logger.info('confirm_eskaera: lang=%s, message="%s"', request.env.lang, message) + _logger.info( + 'confirm_eskaera: lang=%s, message="%s"', request.env.lang, message + ) except Exception: - _logger.info('confirm_eskaera: message logging failed') + _logger.info("confirm_eskaera: message logging failed") - _logger.info('Order %d confirmed successfully, sale.order created: %d', - order_id, sale_order.id) + _logger.info( + "Order %d confirmed successfully, sale.order created: %d", + order_id, + sale_order.id, + ) # Confirm the sale.order (change state from draft to sale) try: sale_order.action_confirm() - _logger.info('sale.order %d confirmed (state changed to sale)', sale_order.id) + _logger.info( + "sale.order %d confirmed (state changed to sale)", sale_order.id + ) except Exception as e: - _logger.warning('Failed to confirm sale.order %d: %s', sale_order.id, str(e)) + _logger.warning( + "Failed to confirm sale.order %d: %s", sale_order.id, str(e) + ) # Continue anyway, the order was created/updated return request.make_response( - json.dumps(response_data), - [('Content-Type', 'application/json')]) + json.dumps(response_data), [("Content-Type", "application/json")] + ) except Exception as e: import traceback - _logger.error('confirm_eskaera: Unexpected error: %s', str(e)) + + _logger.error("confirm_eskaera: Unexpected error: %s", str(e)) _logger.error(traceback.format_exc()) return request.make_response( - json.dumps({'error': str(e)}), - [('Content-Type', 'application/json')], - status=500) - @http.route(['/eskaera//load-from-history/'], - type='http', auth='user', website=True) + json.dumps({"error": str(e)}), + [("Content-Type", "application/json")], + status=500, + ) + + @http.route( + ["/eskaera//load-from-history/"], + type="http", + auth="user", + website=True, + ) def load_order_from_history(self, group_order_id=None, sale_order_id=None, **post): - '''Load a historical order (draft/confirmed) back into the cart. - + """Load a historical order (draft/confirmed) back into the cart. + Used by portal "Load in Cart" button on My Orders page. Extracts items from the order and redirects to the group order page, where the JavaScript auto-load will populate the cart. - ''' + """ try: # Get the sale.order record - sale_order = request.env['sale.order'].browse(sale_order_id) + sale_order = request.env["sale.order"].browse(sale_order_id) if not sale_order.exists(): - return request.redirect('/shop') + return request.redirect("/shop") # Verify this order belongs to current user current_partner = request.env.user.partner_id if sale_order.partner_id.id != current_partner.id: _logger.warning( - 'User %s attempted to load order %d belonging to partner %d', - request.env.user.login, sale_order_id, sale_order.partner_id.id + "User %s attempted to load order %d belonging to partner %d", + request.env.user.login, + sale_order_id, + sale_order.partner_id.id, ) - return request.redirect('/shop') + return request.redirect("/shop") # Verify the order belongs to the requested group_order if sale_order.group_order_id.id != group_order_id: - return request.redirect('/eskaera/%d' % sale_order.group_order_id.id) + return request.redirect("/eskaera/%d" % sale_order.group_order_id.id) # Extract items from the order (skip delivery product) delivery_product = request.env.ref( - 'website_sale_aplicoop.product_home_delivery', - raise_if_not_found=False + "website_sale_aplicoop.product_home_delivery", raise_if_not_found=False ) delivery_product_id = delivery_product.id if delivery_product else None @@ -1616,242 +2030,289 @@ class AplicoopWebsiteSale(WebsiteSale): if delivery_product_id and line.product_id.id == delivery_product_id: continue - items.append({ - 'product_id': line.product_id.id, - 'product_name': line.product_id.name, - 'quantity': line.product_uom_qty, - 'price': line.price_unit, # Unit price - }) + items.append( + { + "product_id": line.product_id.id, + "product_name": line.product_id.name, + "quantity": line.product_uom_qty, + "price": line.price_unit, # Unit price + } + ) # Store items in localStorage by passing via URL parameter or session # We'll use sessionStorage in JavaScript to avoid URL length limits - + # Get the current group order for comparison - current_group_order = request.env['group.order'].browse(group_order_id) - + current_group_order = request.env["group.order"].browse(group_order_id) + # Check if the order being loaded is from the same group order # If not, don't restore the old pickup fields - use the current group order's fields same_group_order = sale_order.group_order_id.id == group_order_id - + # If loading from same group order, restore old pickup fields # Otherwise, page will show current group order's pickup fields pickup_day_to_restore = sale_order.pickup_day if same_group_order else None - pickup_date_to_restore = str(sale_order.pickup_date) if (same_group_order and sale_order.pickup_date) else None - home_delivery_to_restore = sale_order.home_delivery if same_group_order else None - + pickup_date_to_restore = ( + str(sale_order.pickup_date) + if (same_group_order and sale_order.pickup_date) + else None + ) + home_delivery_to_restore = ( + sale_order.home_delivery if same_group_order else None + ) + response = request.make_response( - request.render('website_sale_aplicoop.eskaera_load_from_history', { - 'group_order_id': group_order_id, - 'items_json': json.dumps(items), # Pass serialized JSON - 'sale_order': sale_order, - 'sale_order_name': sale_order.name, # Pass order reference - 'pickup_day': pickup_day_to_restore, # Pass pickup day (or None if different group) - 'pickup_date': pickup_date_to_restore, # Pass pickup date (or None if different group) - 'home_delivery': home_delivery_to_restore, # Pass home delivery flag (or None if different group) - 'same_group_order': same_group_order, # Indicate if from same group order - }), + request.render( + "website_sale_aplicoop.eskaera_load_from_history", + { + "group_order_id": group_order_id, + "items_json": json.dumps(items), # Pass serialized JSON + "sale_order": sale_order, + "sale_order_name": sale_order.name, # Pass order reference + "pickup_day": pickup_day_to_restore, # Pass pickup day (or None if different group) + "pickup_date": pickup_date_to_restore, # Pass pickup date (or None if different group) + "home_delivery": home_delivery_to_restore, # Pass home delivery flag (or None if different group) + "same_group_order": same_group_order, # Indicate if from same group order + }, + ), ) return response except Exception as e: - _logger.error('load_order_from_history: %s', str(e)) + _logger.error("load_order_from_history: %s", str(e)) import traceback + _logger.error(traceback.format_exc()) - return request.redirect('/eskaera/%d' % group_order_id) - @http.route(['/eskaera//confirm/'], - type='json', auth='user', website=True, methods=['POST']) - def confirm_order_from_portal(self, group_order_id=None, sale_order_id=None, **post): - '''Confirm a draft order from the portal (AJAX endpoint). - + return request.redirect("/eskaera/%d" % group_order_id) + + @http.route( + ["/eskaera//confirm/"], + type="json", + auth="user", + website=True, + methods=["POST"], + ) + def confirm_order_from_portal( + self, group_order_id=None, sale_order_id=None, **post + ): + """Confirm a draft order from the portal (AJAX endpoint). + Used by portal "Confirm" button on My Orders page. Confirms the draft order and returns JSON response. Does NOT redirect - the calling JavaScript handles the response. - ''' - _logger.info('confirm_order_from_portal called: group_order_id=%s, sale_order_id=%s', group_order_id, sale_order_id) - + """ + _logger.info( + "confirm_order_from_portal called: group_order_id=%s, sale_order_id=%s", + group_order_id, + sale_order_id, + ) + try: # Get the sale.order record - sale_order = request.env['sale.order'].browse(sale_order_id) + sale_order = request.env["sale.order"].browse(sale_order_id) if not sale_order.exists(): - _logger.warning('confirm_order_from_portal: Order %d not found', sale_order_id) - return { - 'success': False, - 'error': 'Order not found' - } + _logger.warning( + "confirm_order_from_portal: Order %d not found", sale_order_id + ) + return {"success": False, "error": "Order not found"} # Verify this order belongs to current user current_partner = request.env.user.partner_id if sale_order.partner_id.id != current_partner.id: _logger.warning( - 'User %s attempted to confirm order %d belonging to partner %d', - request.env.user.login, sale_order_id, sale_order.partner_id.id + "User %s attempted to confirm order %d belonging to partner %d", + request.env.user.login, + sale_order_id, + sale_order.partner_id.id, ) - return { - 'success': False, - 'error': 'Unauthorized' - } + return {"success": False, "error": "Unauthorized"} # Verify the order belongs to the requested group_order if sale_order.group_order_id.id != group_order_id: - _logger.warning('Order %d belongs to group %d, not %d', sale_order_id, sale_order.group_order_id.id, group_order_id) + _logger.warning( + "Order %d belongs to group %d, not %d", + sale_order_id, + sale_order.group_order_id.id, + group_order_id, + ) return { - 'success': False, - 'error': f'Order belongs to different group: {sale_order.group_order_id.id}' + "success": False, + "error": f"Order belongs to different group: {sale_order.group_order_id.id}", } # Only allow confirming draft orders - if sale_order.state != 'draft': - _logger.warning('Order %d is in state %s, not draft', sale_order_id, sale_order.state) + if sale_order.state != "draft": + _logger.warning( + "Order %d is in state %s, not draft", + sale_order_id, + sale_order.state, + ) return { - 'success': False, - 'error': f'Order is already {sale_order.state}, cannot confirm again' + "success": False, + "error": f"Order is already {sale_order.state}, cannot confirm again", } # Confirm the order (change state to 'sale') sale_order.action_confirm() - _logger.info('Order %d confirmed from portal by user %s', sale_order_id, request.env.user.login) + _logger.info( + "Order %d confirmed from portal by user %s", + sale_order_id, + request.env.user.login, + ) # Return success response with updated order state return { - 'success': True, - 'message': _('Order confirmed successfully'), - 'order_id': sale_order_id, - 'order_state': sale_order.state, - 'group_order_id': group_order_id + "success": True, + "message": _("Order confirmed successfully"), + "order_id": sale_order_id, + "order_state": sale_order.state, + "group_order_id": group_order_id, } except Exception as e: - _logger.error('confirm_order_from_portal: %s', str(e)) + _logger.error("confirm_order_from_portal: %s", str(e)) import traceback + _logger.error(traceback.format_exc()) - return { - 'success': False, - 'error': f'Error confirming order: {str(e)}' - } + return {"success": False, "error": f"Error confirming order: {str(e)}"} + def _translate_labels(self, labels_dict, lang): - '''Manually translate labels based on user language. - + """Manually translate labels based on user language. + This is a fallback translation method for when Odoo's translation system hasn't loaded translations from .po files properly. - ''' + """ translations = { - 'es_ES': { - 'Draft Already Exists': 'El Borrador Ya Existe', - 'A saved draft already exists for this week.': 'Un borrador guardado ya existe para esta semana.', - 'You have two options:': 'Tienes dos opciones:', - 'Option 1: Merge with Existing Draft': 'Opción 1: Fusionar con Borrador Existente', - 'Combine your current cart with the existing draft.': 'Combina tu carrito actual con el borrador existente.', - 'Existing draft has': 'El borrador existente tiene', - 'Current cart has': 'Tu carrito actual tiene', - 'item(s)': 'artículo(s)', - 'Products will be merged by adding quantities. If a product exists in both, quantities will be combined.': 'Los productos se fusionarán sumando cantidades. Si un producto existe en ambos, las cantidades se combinarán.', - 'Option 2: Replace with Current Cart': 'Opción 2: Reemplazar con Carrito Actual', - 'Delete the old draft and save only the current cart items.': 'Elimina el borrador anterior y guarda solo los artículos del carrito actual.', - 'The existing draft will be permanently deleted.': 'El borrador existente se eliminará permanentemente.', - 'Merge': 'Fusionar', - 'Replace': 'Reemplazar', - 'Cancel': 'Cancelar', + "es_ES": { + "Draft Already Exists": "El Borrador Ya Existe", + "A saved draft already exists for this week.": "Un borrador guardado ya existe para esta semana.", + "You have two options:": "Tienes dos opciones:", + "Option 1: Merge with Existing Draft": "Opción 1: Fusionar con Borrador Existente", + "Combine your current cart with the existing draft.": "Combina tu carrito actual con el borrador existente.", + "Existing draft has": "El borrador existente tiene", + "Current cart has": "Tu carrito actual tiene", + "item(s)": "artículo(s)", + "Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Los productos se fusionarán sumando cantidades. Si un producto existe en ambos, las cantidades se combinarán.", + "Option 2: Replace with Current Cart": "Opción 2: Reemplazar con Carrito Actual", + "Delete the old draft and save only the current cart items.": "Elimina el borrador anterior y guarda solo los artículos del carrito actual.", + "The existing draft will be permanently deleted.": "El borrador existente se eliminará permanentemente.", + "Merge": "Fusionar", + "Replace": "Reemplazar", + "Cancel": "Cancelar", # Checkout page labels - 'Home Delivery': 'Entrega a Domicilio', - 'Delivery Information': 'Información de Entrega', - 'Your order will be delivered the day after pickup between 11:00 - 14:00': 'Tu pedido será entregado el día después de la recogida entre las 11:00 - 14:00', - 'Important': 'Importante', - 'Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.': 'Una vez confirmes este pedido, no podrás modificarlo. Por favor, revisa cuidadosamente antes de confirmar.', + "Home Delivery": "Entrega a Domicilio", + "Delivery Information": "Información de Entrega", + "Your order will be delivered the day after pickup between 11:00 - 14:00": "Tu pedido será entregado el día después de la recogida entre las 11:00 - 14:00", + "Important": "Importante", + "Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Una vez confirmes este pedido, no podrás modificarlo. Por favor, revisa cuidadosamente antes de confirmar.", }, - 'eu_ES': { - 'Draft Already Exists': 'Zirriborro Dagoeneko Badago', - 'A saved draft already exists for this week.': 'Gordetako zirriborro bat dagoeneko badago asteburu honetarako.', - 'You have two options:': 'Bi aukera dituzu:', - 'Option 1: Merge with Existing Draft': '1. Aukera: Existentea Duen Zirriborroarekin Batu', - 'Combine your current cart with the existing draft.': 'Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.', - 'Existing draft has': 'Existentea duen zirriborroak du', - 'Current cart has': 'Zure gaur-oraingo saskiak du', - 'item(s)': 'artikulu(a)', - 'Products will be merged by adding quantities. If a product exists in both, quantities will be combined.': 'Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.', - 'Option 2: Replace with Current Cart': '2. Aukera: Gaur-oraingo Askiarekin Ordeztu', - 'Delete the old draft and save only the current cart items.': 'Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.', - 'The existing draft will be permanently deleted.': 'Existentea duen zirriborroa behin betiko ezabatuko da.', - 'Merge': 'Batu', - 'Replace': 'Ordeztu', - 'Cancel': 'Ezeztatu', + "eu_ES": { + "Draft Already Exists": "Zirriborro Dagoeneko Badago", + "A saved draft already exists for this week.": "Gordetako zirriborro bat dagoeneko badago asteburu honetarako.", + "You have two options:": "Bi aukera dituzu:", + "Option 1: Merge with Existing Draft": "1. Aukera: Existentea Duen Zirriborroarekin Batu", + "Combine your current cart with the existing draft.": "Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.", + "Existing draft has": "Existentea duen zirriborroak du", + "Current cart has": "Zure gaur-oraingo saskiak du", + "item(s)": "artikulu(a)", + "Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.", + "Option 2: Replace with Current Cart": "2. Aukera: Gaur-oraingo Askiarekin Ordeztu", + "Delete the old draft and save only the current cart items.": "Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.", + "The existing draft will be permanently deleted.": "Existentea duen zirriborroa behin betiko ezabatuko da.", + "Merge": "Batu", + "Replace": "Ordeztu", + "Cancel": "Ezeztatu", # Checkout page labels - 'Home Delivery': 'Etxera Bidalketa', - 'Delivery Information': 'Bidalketaren Informazioa', - 'Your order will be delivered the day after pickup between 11:00 - 14:00': 'Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean', - 'Important': 'Garrantzitsua', - 'Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.': 'Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.', + "Home Delivery": "Etxera Bidalketa", + "Delivery Information": "Bidalketaren Informazioa", + "Your order will be delivered the day after pickup between 11:00 - 14:00": "Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean", + "Important": "Garrantzitsua", + "Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.", }, # Also support 'eu' as a variant - 'eu': { - 'Draft Already Exists': 'Zirriborro Dagoeneko Badago', - 'A saved draft already exists for this week.': 'Gordetako zirriborro bat dagoeneko badago asteburu honetarako.', - 'You have two options:': 'Bi aukera dituzu:', - 'Option 1: Merge with Existing Draft': '1. Aukera: Existentea Duen Zirriborroarekin Batu', - 'Combine your current cart with the existing draft.': 'Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.', - 'Existing draft has': 'Existentea duen zirriborroak du', - 'Current cart has': 'Zure gaur-oraingo saskiak du', - 'item(s)': 'artikulu(a)', - 'Products will be merged by adding quantities. If a product exists in both, quantities will be combined.': 'Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.', - 'Option 2: Replace with Current Cart': '2. Aukera: Gaur-oraingo Askiarekin Ordeztu', - 'Delete the old draft and save only the current cart items.': 'Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.', - 'The existing draft will be permanently deleted.': 'Existentea duen zirriborroa behin betiko ezabatuko da.', - 'Merge': 'Batu', - 'Replace': 'Ordeztu', - 'Cancel': 'Ezeztatu', + "eu": { + "Draft Already Exists": "Zirriborro Dagoeneko Badago", + "A saved draft already exists for this week.": "Gordetako zirriborro bat dagoeneko badago asteburu honetarako.", + "You have two options:": "Bi aukera dituzu:", + "Option 1: Merge with Existing Draft": "1. Aukera: Existentea Duen Zirriborroarekin Batu", + "Combine your current cart with the existing draft.": "Batu zure gaur-oraingo saskia existentea duen zirriborroarekin.", + "Existing draft has": "Existentea duen zirriborroak du", + "Current cart has": "Zure gaur-oraingo saskiak du", + "item(s)": "artikulu(a)", + "Products will be merged by adding quantities. If a product exists in both, quantities will be combined.": "Produktuak batuko dira kantitateak gehituz. Produktu bat bian badago, kantitateak konbinatuko dira.", + "Option 2: Replace with Current Cart": "2. Aukera: Gaur-oraingo Askiarekin Ordeztu", + "Delete the old draft and save only the current cart items.": "Ezabatu zahar-zirriborroa eta gorde soilik gaur-oraingo saskiaren artikulua.", + "The existing draft will be permanently deleted.": "Existentea duen zirriborroa behin betiko ezabatuko da.", + "Merge": "Batu", + "Replace": "Ordeztu", + "Cancel": "Ezeztatu", # Checkout page labels - 'Home Delivery': 'Etxera Bidalketa', - 'Delivery Information': 'Bidalketaren Informazioa', - 'Your order will be delivered the day after pickup between 11:00 - 14:00': 'Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean', - 'Important': 'Garrantzitsua', - 'Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.': 'Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.', - } + "Home Delivery": "Etxera Bidalketa", + "Delivery Information": "Bidalketaren Informazioa", + "Your order will be delivered the day after pickup between 11:00 - 14:00": "Zure eskaera bidaliko da biltzeko eguaren ondoren 11:00 - 14:00 bitartean", + "Important": "Garrantzitsua", + "Once you confirm this order, you will not be able to modify it. Please review carefully before confirming.": "Behin eskaera hau berretsi ondoren, ezin izango duzu aldatu. Mesedez, arretaz berrikusi berretsi aurretik.", + }, } - + # Get the translation dictionary for the user's language # Try exact match first, then try without the region code (e.g., 'eu' from 'eu_ES') lang_translations = translations.get(lang) - if not lang_translations and '_' in lang: - lang_code = lang.split('_')[0] # Get 'eu' from 'eu_ES' + if not lang_translations and "_" in lang: + lang_code = lang.split("_")[0] # Get 'eu' from 'eu_ES' lang_translations = translations.get(lang_code, {}) if not lang_translations: lang_translations = {} - + # Translate all English labels to the target language translated = {} for key, english_label in labels_dict.items(): translated[key] = lang_translations.get(english_label, english_label) - - _logger.info('[_translate_labels] Language: %s, Translated %d labels', lang, len(translated)) - + + _logger.info( + "[_translate_labels] Language: %s, Translated %d labels", + lang, + len(translated), + ) + return translated - @http.route(['/eskaera/labels', '/eskaera/i18n'], type='json', auth='public', website=True, csrf=False) + @http.route( + ["/eskaera/labels", "/eskaera/i18n"], + type="json", + auth="public", + website=True, + csrf=False, + ) def get_checkout_labels(self, **post): - '''Return ALL translated UI labels and messages unified. - + """Return ALL translated UI labels and messages unified. + This is the SINGLE API ENDPOINT for fetching all user-facing translations. Use this from JavaScript instead of maintaining local translation files. - + The endpoint automatically detects the user's language and returns all UI labels/messages in that language, ready to be used directly. - + Returns: dict: Complete set of translated labels and messages - ''' + """ try: lang = self._get_detected_language(**post) labels = self._get_translated_labels(lang) - - _logger.info('[get_checkout_labels] ✅ SUCCESS - Language: %s, Label count: %d', - lang, len(labels)) - + + _logger.info( + "[get_checkout_labels] ✅ SUCCESS - Language: %s, Label count: %d", + lang, + len(labels), + ) + return labels except Exception as e: - _logger.error('[get_checkout_labels] ❌ ERROR: %s', str(e), exc_info=True) + _logger.error("[get_checkout_labels] ❌ ERROR: %s", str(e), exc_info=True) # Return default English labels as fallback return { - 'save_cart': 'Save Cart', - 'reload_cart': 'Reload Cart', - 'empty_cart': 'Your cart is empty', - 'added_to_cart': 'added to cart', - } \ No newline at end of file + "save_cart": "Save Cart", + "reload_cart": "Reload Cart", + "empty_cart": "Your cart is empty", + "added_to_cart": "added to cart", + } diff --git a/website_sale_aplicoop/tests/test_price_with_taxes_included.py b/website_sale_aplicoop/tests/test_price_with_taxes_included.py new file mode 100644 index 0000000..5bdee16 --- /dev/null +++ b/website_sale_aplicoop/tests/test_price_with_taxes_included.py @@ -0,0 +1,425 @@ +# Copyright 2025 Criptomart +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +""" +Test suite for price calculations WITH taxes included. + +This test verifies that the _compute_price_with_taxes method correctly +calculates prices including taxes for display in the online shop. +""" + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged("post_install", "-at_install") +class TestPriceWithTaxesIncluded(TransactionCase): + """Test that prices displayed include taxes.""" + + def setUp(self): + super().setUp() + + # Create test company + self.company = self.env["res.company"].create( + { + "name": "Test Company Tax Included", + } + ) + + # Get or create default tax group + tax_group = self.env["account.tax.group"].search( + [("company_id", "=", self.company.id)], limit=1 + ) + if not tax_group: + tax_group = self.env["account.tax.group"].create( + { + "name": "IVA", + "company_id": self.company.id, + } + ) + + # Get default country (Spain) + country_es = self.env.ref("base.es") + + # Create tax (21% IVA) - price_include=False (default) + self.tax_21 = self.env["account.tax"].create( + { + "name": "IVA 21%", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + "price_include": False, # Explicit: tax NOT included in price + "company_id": self.company.id, + "country_id": country_es.id, + "tax_group_id": tax_group.id, + } + ) + + # Create tax (10% IVA reducido) + self.tax_10 = self.env["account.tax"].create( + { + "name": "IVA 10%", + "amount": 10.0, + "amount_type": "percent", + "type_tax_use": "sale", + "price_include": False, + "company_id": self.company.id, + "country_id": country_es.id, + "tax_group_id": tax_group.id, + } + ) + + # Create tax with price_include=True for comparison + self.tax_21_included = self.env["account.tax"].create( + { + "name": "IVA 21% Incluido", + "amount": 21.0, + "amount_type": "percent", + "type_tax_use": "sale", + "price_include": True, # Tax IS included in price + "company_id": self.company.id, + "country_id": country_es.id, + "tax_group_id": tax_group.id, + } + ) + + # Create product category + self.category = self.env["product.category"].create( + { + "name": "Test Category Tax Included", + } + ) + + # Create test products with different tax configurations + self.product_21 = self.env["product.product"].create( + { + "name": "Product With 21% Tax", + "list_price": 100.0, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [self.tax_21.id])], + "company_id": self.company.id, + } + ) + + self.product_10 = self.env["product.product"].create( + { + "name": "Product With 10% Tax", + "list_price": 100.0, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [self.tax_10.id])], + "company_id": self.company.id, + } + ) + + self.product_no_tax = self.env["product.product"].create( + { + "name": "Product Without Tax", + "list_price": 100.0, + "categ_id": self.category.id, + "taxes_id": False, + "company_id": self.company.id, + } + ) + + self.product_tax_included = self.env["product.product"].create( + { + "name": "Product With Tax Included", + "list_price": 121.0, # 100 + 21% = 121 + "categ_id": self.category.id, + "taxes_id": [(6, 0, [self.tax_21_included.id])], + "company_id": self.company.id, + } + ) + + # Create pricelist + self.pricelist = self.env["product.pricelist"].create( + { + "name": "Test Pricelist", + "company_id": self.company.id, + } + ) + + def test_price_with_21_percent_tax(self): + """Test that 21% tax is correctly added to base price.""" + # Base price: 100.0 + # Expected with 21% tax: 121.0 + + taxes = self.product_21.taxes_id.filtered( + lambda t: t.company_id == self.company + ) + + base_price = 100.0 + tax_result = taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=self.product_21, + ) + + price_with_tax = tax_result["total_included"] + + self.assertAlmostEqual( + price_with_tax, 121.0, places=2, msg="100 + 21% should equal 121.0" + ) + + def test_price_with_10_percent_tax(self): + """Test that 10% tax is correctly added to base price.""" + # Base price: 100.0 + # Expected with 10% tax: 110.0 + + taxes = self.product_10.taxes_id.filtered( + lambda t: t.company_id == self.company + ) + + base_price = 100.0 + tax_result = taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=self.product_10, + ) + + price_with_tax = tax_result["total_included"] + + self.assertAlmostEqual( + price_with_tax, 110.0, places=2, msg="100 + 10% should equal 110.0" + ) + + def test_price_without_tax(self): + """Test that product without tax returns base price unchanged.""" + # Base price: 100.0 + # Expected with no tax: 100.0 + + taxes = self.product_no_tax.taxes_id.filtered( + lambda t: t.company_id == self.company + ) + + # No taxes, so tax_result would be empty + self.assertFalse(taxes, "Product should have no taxes") + + # Without taxes, price should remain base price + base_price = 100.0 + expected_price = 100.0 + + self.assertEqual( + base_price, + expected_price, + msg="Product without tax should have unchanged price", + ) + + def test_oca_get_price_returns_base_without_tax(self): + """Test that OCA _get_price returns base price WITHOUT taxes by default.""" + # This verifies our understanding of OCA behavior + + price_info = self.product_21._get_price( + qty=1.0, + pricelist=self.pricelist, + fposition=False, + ) + + # OCA should return base price (100.0) WITHOUT tax + self.assertAlmostEqual( + price_info["value"], + 100.0, + places=2, + msg="OCA _get_price should return base price without tax", + ) + + # tax_included should be False for price_include=False taxes + self.assertFalse( + price_info.get("tax_included", False), + msg="tax_included should be False when price_include=False", + ) + + def test_oca_get_price_with_included_tax(self): + """Test OCA behavior with price_include=True tax.""" + + price_info = self.product_tax_included._get_price( + qty=1.0, + pricelist=self.pricelist, + fposition=False, + ) + + # With price_include=True, the price should already include tax + # list_price is 121.0 (100 + 21%) + self.assertAlmostEqual( + price_info["value"], + 121.0, + places=2, + msg="Price with included tax should be 121.0", + ) + + # tax_included should be True + self.assertTrue( + price_info.get("tax_included", False), + msg="tax_included should be True when price_include=True", + ) + + def test_compute_all_with_multiple_taxes(self): + """Test tax calculation with multiple taxes.""" + # Create product with both 21% and 10% taxes + product_multi = self.env["product.product"].create( + { + "name": "Product With Multiple Taxes", + "list_price": 100.0, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [self.tax_21.id, self.tax_10.id])], + "company_id": self.company.id, + } + ) + + taxes = product_multi.taxes_id.filtered(lambda t: t.company_id == self.company) + + base_price = 100.0 + tax_result = taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=product_multi, + ) + + price_with_taxes = tax_result["total_included"] + + # 100 + 21% + 10% = 100 + 21 + 10 = 131.0 + self.assertAlmostEqual( + price_with_taxes, 131.0, places=2, msg="100 + 21% + 10% should equal 131.0" + ) + + def test_compute_all_with_fiscal_position(self): + """Test tax calculation with fiscal position mapping.""" + # Create fiscal position that maps 21% to 10% + fiscal_position = self.env["account.fiscal.position"].create( + { + "name": "Test Fiscal Position", + "company_id": self.company.id, + } + ) + self.env["account.fiscal.position.tax"].create( + { + "position_id": fiscal_position.id, + "tax_src_id": self.tax_21.id, + "tax_dest_id": self.tax_10.id, + } + ) + + # Get taxes and apply fiscal position + taxes = self.product_21.taxes_id.filtered( + lambda t: t.company_id == self.company + ) + mapped_taxes = fiscal_position.map_tax(taxes) + + # Should be mapped to 10% tax + self.assertEqual(len(mapped_taxes), 1) + self.assertEqual(mapped_taxes[0].id, self.tax_10.id) + + base_price = 100.0 + tax_result = mapped_taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=self.product_21, + ) + + price_with_tax = tax_result["total_included"] + + # Should be 110.0 (10% instead of 21%) + self.assertAlmostEqual( + price_with_tax, 110.0, places=2, msg="Fiscal position should map to 10% tax" + ) + + def test_tax_amount_details(self): + """Test that compute_all provides detailed tax breakdown.""" + taxes = self.product_21.taxes_id.filtered( + lambda t: t.company_id == self.company + ) + + base_price = 100.0 + tax_result = taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=self.product_21, + ) + + # Verify structure of tax_result + self.assertIn("total_included", tax_result) + self.assertIn("total_excluded", tax_result) + self.assertIn("taxes", tax_result) + + # total_excluded should be base price + self.assertAlmostEqual(tax_result["total_excluded"], 100.0, places=2) + + # total_included should be base + tax + self.assertAlmostEqual(tax_result["total_included"], 121.0, places=2) + + # taxes should contain tax details + self.assertEqual(len(tax_result["taxes"]), 1) + tax_detail = tax_result["taxes"][0] + self.assertAlmostEqual(tax_detail["amount"], 21.0, places=2) + + def test_zero_price_with_tax(self): + """Test tax calculation on free product.""" + free_product = self.env["product.product"].create( + { + "name": "Free Product With Tax", + "list_price": 0.0, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [self.tax_21.id])], + "company_id": self.company.id, + } + ) + + taxes = free_product.taxes_id.filtered(lambda t: t.company_id == self.company) + + base_price = 0.0 + tax_result = taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=free_product, + ) + + price_with_tax = tax_result["total_included"] + + # 0 + 21% = 0 + self.assertAlmostEqual( + price_with_tax, + 0.0, + places=2, + msg="Free product with tax should still be free", + ) + + def test_high_precision_price_with_tax(self): + """Test tax calculation with high precision prices.""" + precise_product = self.env["product.product"].create( + { + "name": "Precise Price Product", + "list_price": 99.99, + "categ_id": self.category.id, + "taxes_id": [(6, 0, [self.tax_21.id])], + "company_id": self.company.id, + } + ) + + taxes = precise_product.taxes_id.filtered( + lambda t: t.company_id == self.company + ) + + base_price = 99.99 + tax_result = taxes.compute_all( + base_price, + currency=self.env.company.currency_id, + quantity=1.0, + product=precise_product, + ) + + price_with_tax = tax_result["total_included"] + + # 99.99 + 21% = 120.9879 ≈ 120.99 + expected = 99.99 * 1.21 + self.assertAlmostEqual( + price_with_tax, + expected, + places=2, + msg=f"Expected {expected}, got {price_with_tax}", + )