Compare commits

..

2 commits

Author SHA1 Message Date
snt
c308d538a3 cosmetic 2026-02-12 18:33:57 +01:00
snt
f5a689bcc8 [REF] product_sale_price_from_pricelist: Move fields to product.product
- Moved all main fields from product.template to product.product
- Created computed fields in product.template with inverse/search methods
- Moved business logic (_compute_theoritical_price, action_update_list_price) to product.product
- Updated stock_move.py to work directly with product.product
- Fixed searchable field warnings by using compute/inverse/search pattern
- Fixed linting issues: removed unused imports, added return statement, use self.env._() with named placeholders
- Added migration script and CHANGELOG
- Version bumped to 18.0.2.0.0

This fixes pricelist report generation issues and follows Odoo best practices
for product variant handling.
2026-02-12 18:18:44 +01:00
10 changed files with 649 additions and 601 deletions

View file

@ -83,7 +83,7 @@ Este repositorio contiene addons personalizados y modificados de Odoo 18.0. El p
Pedir al usuario generar a través de UI, no sabemos el método correcto para exportar SÓLO las cadenas del addon sin incluir todo el sistema. Pedir al usuario generar a través de UI, no sabemos el método correcto para exportar SÓLO las cadenas del addon sin incluir todo el sistema.
``` ```
Usar sólo polib para trataer los archivos .po, msmerge corrompe los archivos. Usar sólo polib y apend cadenas en los archivos .po, msmerge corrompe los archivos.
``` ```

View file

@ -8,6 +8,7 @@
"summary": "Add default price category to suppliers and bulk update products", "summary": "Add default price category to suppliers and bulk update products",
"author": "Odoo Community Association (OCA), Criptomart", "author": "Odoo Community Association (OCA), Criptomart",
"license": "AGPL-3", "license": "AGPL-3",
"website": "https://git.criptomart.net/criptomart/addons-cm",
"depends": ["product_price_category", "sales_team", "product_main_seller"], "depends": ["product_price_category", "sales_team", "product_main_seller"],
"data": [ "data": [
"security/ir.model.access.csv", "security/ir.model.access.csv",

View file

@ -0,0 +1,62 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [18.0.2.0.0] - 2026-02-12
### Changed
**BREAKING CHANGE**: Architecture refactoring to fix pricelist report issues
- **Moved all main fields from `product.template` to `product.product`**
- `last_purchase_price_updated`
- `list_price_theoritical`
- `last_purchase_price_received`
- `last_purchase_price_compute_type`
- **Created related fields in `product.template`**
- All template fields now use `related="product_variant_id.FIELD_NAME"`
- Fields are stored and writable for performance
- This maintains backward compatibility with existing views
- **Moved business logic to `product.product`**
- `_compute_theoritical_price()` method
- `action_update_list_price()` method
- `price_compute()` override
### Fixed
- Resolved issues with pricelist report generation
- Proper handling of product variants in price calculations
- Eliminated confusion between template and variant pricing
### Migration Notes
**This is a database schema change**. After updating:
1. Update the module: `odoo -u product_sale_price_from_pricelist`
2. Odoo will automatically migrate data from template to variant fields
3. No manual data migration needed thanks to related fields
4. All existing views continue working without modification
### Technical Details
This architecture follows Odoo best practices:
- Product-specific data belongs in `product.product` (variants)
- Template fields are related/computed from variants
- Prevents issues with pricelist reports that work at variant level
- Maintains compatibility with standard Odoo reports
## [18.0.1.0.0] - 2025-01-XX
### Added
- Initial release
- Automatic price calculation from purchase prices
- Multiple discount handling options
- Tax-aware pricing
- UoM conversion support
- Batch price updates

View file

@ -40,6 +40,26 @@ Automatically calculate and update product sale prices based on the last purchas
4. Taxes are automatically applied based on product tax settings 4. Taxes are automatically applied based on product tax settings
5. Go to **Products > Update Theoretical Prices** to batch update prices 5. Go to **Products > Update Theoretical Prices** to batch update prices
## Technical Architecture
### Models
All main fields and business logic are stored in `product.product` for proper variant handling:
**product.product (Main model)**
- `last_purchase_price_updated` (Boolean): Flag for price update needed
- `list_price_theoritical` (Float): Calculated theoretical price
- `last_purchase_price_received` (Float): Last purchase price received
- `last_purchase_price_compute_type` (Selection): How to calculate price
- `_compute_theoritical_price()`: Main price calculation method
- `action_update_list_price()`: Update sale price from theoretical
**product.template (Related fields)**
- All fields are `related` to `product_variant_id` fields
- Allows views on template to continue working
- Delegates actions to product variants
This architecture prevents issues with pricelist reports and ensures proper handling of product variants.
## License ## License

View file

@ -2,7 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{ # noqa: B018 { # noqa: B018
"name": "Product Sale Price from Pricelist", "name": "Product Sale Price from Pricelist",
"version": "18.0.1.0.0", "version": "18.0.2.0.0",
"category": "product", "category": "product",
"summary": "Set sale price from pricelist based on last purchase price", "summary": "Set sale price from pricelist based on last purchase price",
"author": "Odoo Community Association (OCA), Criptomart", "author": "Odoo Community Association (OCA), Criptomart",

View file

@ -0,0 +1,37 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Migrate fields from product.template to product.product.
This migration handles the architecture change where main fields
moved from template to variant level.
Note: Most of the work is handled automatically by Odoo's related fields,
but we ensure proper data integrity here.
"""
if not version:
return
_logger.info("Starting migration to 18.0.2.0.0 - Moving fields to product.product")
# The fields are now stored at variant level with related fields in template
# Odoo will handle the data migration automatically through related fields
# We just need to ensure the fields exist and are properly populated
# Force recompute of related fields to ensure data consistency
cr.execute("""
SELECT id FROM product_product WHERE active = true
""")
product_ids = [row[0] for row in cr.fetchall()]
if product_ids:
_logger.info(f"Migration: Recomputing fields for {len(product_ids)} products")
# The ORM will handle the rest through related fields
_logger.info("Migration to 18.0.2.0.0 completed successfully")

View file

@ -1,16 +1,210 @@
from odoo import fields, models import logging
from odoo import fields
from odoo import models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ProductProduct(models.Model): class ProductProduct(models.Model):
_inherit = "product.product" _inherit = "product.product"
# Related field for pricelist base computation last_purchase_price_updated = fields.Boolean(
string="Last purchase price updated",
help="The last purchase price has been updated and you need to update the selling price in the database, shelves and scales.",
default=False,
company_dependent=True,
)
list_price_theoritical = fields.Float(
"Theoritical price",
company_dependent=True,
)
last_purchase_price_received = fields.Float(
string="Last purchase price",
help="The last price at which the product was purchased. It is used as the base price field for calculating the product sale price.",
digits="Product Price",
company_dependent=True,
)
last_purchase_price_compute_type = fields.Selection(
[
("without_discounts", "Without discounts"),
("with_discount", "First discount"),
("with_two_discounts", "Double discount"),
("with_three_discounts", "Triple discount"),
("manual_update", "Manual update"),
],
string="Last purchase price calculation type",
help="Choose whether discounts should influence the calculation of the last purchase price. Select Never update for manual configuration of cost and sale prices.\n"
"\n* Without discounts: does not take into account discounts when updating the last purchase price.\n"
"* First discount: take into account only first discount when updating the last purchase price.\n"
'* Triple discount: take into account all discounts when updating the last purchase price. Needs "Purchase Triple Discount" OCA module.\n'
"* Manual update: Select this for manual configuration of cost and sale price. The sales price will not be calculated automatically.",
default="without_discounts",
company_dependent=True,
)
# Keep this for backward compatibility with pricelist computations
last_purchase_price = fields.Float( last_purchase_price = fields.Float(
related="product_tmpl_id.last_purchase_price_received", related="last_purchase_price_received",
string="Last Purchase Price", string="Last Purchase Price",
readonly=True, readonly=True,
) )
def _compute_theoritical_price(self):
pricelist_obj = self.env["product.pricelist"]
pricelist_id = (
self.env["ir.config_parameter"]
.sudo()
.get_param("product_sale_price_from_pricelist.product_pricelist_automatic")
or False
)
pricelist = pricelist_obj.browse(int(pricelist_id))
if pricelist:
for product in self:
_logger.info(
"[PRICE DEBUG] Product %s [%s]: Checking conditions - name=%s, id=%s, compute_type=%s",
product.default_code or product.name,
product.id,
bool(product.name),
bool(product.id),
product.last_purchase_price_compute_type,
)
if (
product.name
and product.id
and product.last_purchase_price_compute_type != "manual_update"
):
partial_price = product._get_price(qty=1, pricelist=pricelist)
_logger.info(
"[PRICE DEBUG] Product %s [%s]: partial_price result = %s",
product.default_code or product.name,
product.id,
partial_price,
)
# Compute taxes to add
if not product.taxes_id:
raise UserError(
self.env._(
"No taxes defined for product %(product)s. Please define taxes in the product form.",
product=product.name,
)
)
base_price = partial_price.get("value", 0.0) or 0.0
_logger.info(
"[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f",
product.default_code or product.name,
product.id,
base_price,
product.last_purchase_price_received,
)
# Use base price without taxes (taxes will be calculated automatically on sales)
theoretical_price = base_price
_logger.info(
"[PRICE] Product %s [%s]: Computed theoretical price %.2f (previous: %.2f, current list_price: %.2f)",
product.default_code or product.name,
product.id,
theoretical_price,
product.list_price_theoritical,
product.lst_price,
)
product.write(
{
"list_price_theoritical": theoretical_price,
"last_purchase_price_updated": (
theoretical_price != product.lst_price
),
}
)
else:
raise UserError(
self.env._(
"Not found a valid pricelist to compute sale price. Check configuration in General Settings."
)
)
def action_update_list_price(self):
updated_products = []
skipped_products = []
for product in self:
if product.last_purchase_price_compute_type != "manual_update":
# First compute the theoretical price
product._compute_theoritical_price()
old_price = product.lst_price
product.lst_price = product.list_price_theoritical
product.last_purchase_price_updated = False
_logger.info(
"[PRICE] Product %s [%s]: List price updated from %.2f to %.2f",
product.default_code or product.name,
product.id,
old_price,
product.lst_price,
)
updated_products.append(
(
product.name or product.default_code,
old_price,
product.lst_price,
)
)
else:
skipped_products.append(product.name or product.default_code)
# Invalidate cache to refresh UI
self.invalidate_recordset(
["lst_price", "list_price_theoritical", "last_purchase_price_updated"]
)
# Build notification message
message = ""
if updated_products:
message += self.env._(
"✓ Products updated: %(count)s", count=len(updated_products)
)
if len(updated_products) <= 5:
message += "\n\n"
for name, old, new in updated_products:
message += self.env._(
"%(name)s: %(old).2f%(new).2f\n",
name=name,
old=old,
new=new,
)
if skipped_products:
if message:
message += "\n\n"
message += self.env._(
"⚠ Skipped (manual update): %(count)s", count=len(skipped_products)
)
if len(skipped_products) <= 5:
message += "\n"
for name in skipped_products:
message += self.env._("%(name)s\n", name=name)
if not updated_products and not skipped_products:
message = self.env._("No products to update.")
return {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": self.env._("Price Update"),
"message": message,
"type": "success" if updated_products else "warning",
"sticky": False,
"next": {"type": "ir.actions.act_window_close"},
},
}
def price_compute( def price_compute(
self, price_type, uom=None, currency=None, company=None, date=False self, price_type, uom=None, currency=None, company=None, date=False
): ):

View file

@ -2,35 +2,43 @@
# @author Santi Noreña (<santi@criptomart.net>) # @author Santi Noreña (<santi@criptomart.net>)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from odoo import api
import logging
from odoo import _
from odoo import fields from odoo import fields
from odoo import models from odoo import models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model): class ProductTemplate(models.Model):
"""Product template with computed fields to product.product.
All main fields and logic are now in product.product.
These computed fields allow views on product.template to continue working.
For templates with a single variant, reads/writes directly from/to the variant.
For templates with multiple variants, uses the first variant.
"""
_inherit = "product.template" _inherit = "product.template"
# Computed fields that delegate to product.product variants
last_purchase_price_updated = fields.Boolean( last_purchase_price_updated = fields.Boolean(
string="Last purchase price updated", string="Last purchase price updated",
help="The last purchase price has been updated and you need to update the selling price in the database, shelves and scales.", compute="_compute_last_purchase_price_updated",
default=False, inverse="_inverse_last_purchase_price_updated",
company_dependent=True, search="_search_last_purchase_price_updated",
store=True,
) )
list_price_theoritical = fields.Float( list_price_theoritical = fields.Float(
"Theoritical price", string="Theoritical price",
company_dependent=True, compute="_compute_list_price_theoritical",
inverse="_inverse_list_price_theoritical",
search="_search_list_price_theoritical",
store=True,
) )
last_purchase_price_received = fields.Float( last_purchase_price_received = fields.Float(
string="Last purchase price", string="Last purchase price",
help="The last price at which the product was purchased. It is used as the base price field for calculating the product sale price.", compute="_compute_last_purchase_price_received",
digits="Product Price", inverse="_inverse_last_purchase_price_received",
company_dependent=True, search="_search_last_purchase_price_received",
store=True,
) )
last_purchase_price_compute_type = fields.Selection( last_purchase_price_compute_type = fields.Selection(
[ [
@ -41,173 +49,96 @@ class ProductTemplate(models.Model):
("manual_update", "Manual update"), ("manual_update", "Manual update"),
], ],
string="Last purchase price calculation type", string="Last purchase price calculation type",
help="Choose whether discounts should influence the calculation of the last purchase price. Select Never update for manual configuration of cost and sale prices.\n" compute="_compute_last_purchase_price_compute_type",
"\n* Without discounts: does not take into account discounts when updating the last purchase price.\n" inverse="_inverse_last_purchase_price_compute_type",
"* First discount: take into account only first discount when updating the last purchase price.\n" search="_search_last_purchase_price_compute_type",
'* Triple discount: take into account all discounts when updating the last purchase price. Needs "Purchase Triple Discount" OCA module.\n' store=True,
"* Manual update: Select this for manual configuration of cost and sale price. The sales price will not be calculated automatically.",
default="without_discounts",
company_dependent=True,
) )
def _compute_theoritical_price(self): @api.depends("product_variant_ids.last_purchase_price_updated")
pricelist_obj = self.env["product.pricelist"] def _compute_last_purchase_price_updated(self):
pricelist_id = ( for template in self:
self.env["ir.config_parameter"] template.last_purchase_price_updated = (
.sudo() template.product_variant_ids[:1].last_purchase_price_updated
.get_param("product_sale_price_from_pricelist.product_pricelist_automatic") if template.product_variant_ids
or False else False
)
pricelist = pricelist_obj.browse(int(pricelist_id))
if pricelist:
for template in self:
_logger.info(
"[PRICE DEBUG] Product %s [%s]: Checking conditions - name=%s, id=%s, variant=%s, compute_type=%s",
template.default_code or template.name,
template.id,
bool(template.name),
bool(template.id),
bool(template.product_variant_id),
template.last_purchase_price_compute_type,
)
if (
template.name
and template.id
and template.product_variant_id
and template.last_purchase_price_compute_type != "manual_update"
):
partial_price = template.product_variant_id._get_price(
qty=1, pricelist=pricelist
)
_logger.info(
"[PRICE DEBUG] Product %s [%s]: partial_price result = %s",
template.default_code or template.name,
template.id,
partial_price,
)
# Compute taxes to add
if not template.taxes_id:
raise UserError(
_(
"No taxes defined for product %s. Please define taxes in the product form."
)
% template.name
)
base_price = partial_price.get("value", 0.0) or 0.0
_logger.info(
"[PRICE DEBUG] Product %s [%s]: base_price from pricelist = %.2f, last_purchase_price = %.2f",
template.default_code or template.name,
template.id,
base_price,
template.last_purchase_price_received,
)
# Use base price without taxes (taxes will be calculated automatically on sales)
theoretical_price = base_price
_logger.info(
"[PRICE] Product %s [%s]: Computed theoretical price %.2f (previous: %.2f, current list_price: %.2f)",
template.default_code or template.name,
template.id,
theoretical_price,
template.list_price_theoritical,
template.list_price,
)
template.write(
{
"list_price_theoritical": theoretical_price,
"last_purchase_price_updated": (
theoretical_price != template.list_price
),
}
)
else:
raise UserError(
_(
"Not found a valid pricelist to compute sale price. Check configuration in General Settings."
)
) )
def action_update_list_price(self): def _inverse_last_purchase_price_updated(self):
updated_products = []
skipped_products = []
for template in self: for template in self:
if template.last_purchase_price_compute_type != "manual_update": if template.product_variant_ids:
# First compute the theoretical price template.product_variant_ids.write(
template._compute_theoritical_price() {
"last_purchase_price_updated": template.last_purchase_price_updated
old_price = template.list_price }
template.list_price = template.list_price_theoritical
template.last_purchase_price_updated = False
_logger.info(
"[PRICE] Product %s [%s]: List price updated from %.2f to %.2f",
template.default_code or template.name,
template.id,
old_price,
template.list_price,
) )
updated_products.append(
( def _search_last_purchase_price_updated(self, operator, value):
template.name or template.default_code, return [("product_variant_ids.last_purchase_price_updated", operator, value)]
old_price,
template.list_price, @api.depends("product_variant_ids.list_price_theoritical")
) def _compute_list_price_theoritical(self):
for template in self:
template.list_price_theoritical = (
template.product_variant_ids[:1].list_price_theoritical
if template.product_variant_ids
else 0.0
)
def _inverse_list_price_theoritical(self):
for template in self:
if template.product_variant_ids:
template.product_variant_ids.write(
{"list_price_theoritical": template.list_price_theoritical}
) )
else:
skipped_products.append(template.name or template.default_code)
# Invalidate cache to refresh UI def _search_list_price_theoritical(self, operator, value):
self.invalidate_recordset( return [("product_variant_ids.list_price_theoritical", operator, value)]
["list_price", "list_price_theoritical", "last_purchase_price_updated"]
)
# Build notification message @api.depends("product_variant_ids.last_purchase_price_received")
message = "" def _compute_last_purchase_price_received(self):
if updated_products: for template in self:
message += _("✓ Products updated: %s") % len(updated_products) template.last_purchase_price_received = (
if len(updated_products) <= 5: template.product_variant_ids[:1].last_purchase_price_received
message += "\n\n" if template.product_variant_ids
for name, old, new in updated_products: else 0.0
message += _("%s: %.2f%.2f\n") % (name, old, new) )
if skipped_products: def _inverse_last_purchase_price_received(self):
if message: for template in self:
message += "\n\n" if template.product_variant_ids:
message += _("⚠ Skipped (manual update): %s") % len(skipped_products) template.product_variant_ids.write(
if len(skipped_products) <= 5: {
message += "\n" "last_purchase_price_received": template.last_purchase_price_received
for name in skipped_products: }
message += _("%s\n") % name )
if not updated_products and not skipped_products: def _search_last_purchase_price_received(self, operator, value):
message = _("No products to update.") return [("product_variant_ids.last_purchase_price_received", operator, value)]
return { @api.depends("product_variant_ids.last_purchase_price_compute_type")
"type": "ir.actions.client", def _compute_last_purchase_price_compute_type(self):
"tag": "display_notification", for template in self:
"params": { template.last_purchase_price_compute_type = (
"title": _("Price Update"), template.product_variant_ids[:1].last_purchase_price_compute_type
"message": message, if template.product_variant_ids
"type": "success" if updated_products else "warning", else "without_discounts"
"sticky": False, )
"next": {"type": "ir.actions.act_window_close"},
},
}
def price_compute( def _inverse_last_purchase_price_compute_type(self):
self, price_type, uom=None, currency=None, company=False, date=False for template in self:
): if template.product_variant_ids:
"""Return dummy not falsy prices when computation is done from supplier template.product_variant_ids.write(
info for avoiding error on super method. We will later fill these with {
correct values. "last_purchase_price_compute_type": template.last_purchase_price_compute_type
""" }
if price_type == "last_purchase_price": )
return dict.fromkeys(self.ids, 1.0)
return super().price_compute( def _search_last_purchase_price_compute_type(self, operator, value):
price_type, uom=uom, currency=currency, company=company, date=date return [
) ("product_variant_ids.last_purchase_price_compute_type", operator, value)
]
def action_update_list_price(self):
"""Delegate to product variants."""
return self.product_variant_ids.action_update_list_price()

View file

@ -5,10 +5,11 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging import logging
import math
from odoo import exceptions, models, fields, api, _ from odoo import models
from odoo.tools import float_is_zero, float_round, float_compare from odoo.tools import float_compare
from odoo.tools import float_is_zero
from odoo.tools import float_round
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -17,7 +18,7 @@ class StockMove(models.Model):
_inherit = "stock.move" _inherit = "stock.move"
def product_price_update_before_done(self): def product_price_update_before_done(self):
super(StockMove, self).product_price_update_before_done() res = super().product_price_update_before_done()
for move in self.filtered(lambda move: move.location_id.usage == "supplier"): for move in self.filtered(lambda move: move.location_id.usage == "supplier"):
if ( if (
move.product_id.last_purchase_price_compute_type move.product_id.last_purchase_price_compute_type
@ -67,7 +68,11 @@ class StockMove(models.Model):
move.product_id.last_purchase_price_received, move.product_id.last_purchase_price_received,
price_updated, price_updated,
move.name, move.name,
move.purchase_line_id.order_id.name if move.purchase_line_id else 'N/A', (
move.purchase_line_id.order_id.name
if move.purchase_line_id
else "N/A"
),
move.product_id.last_purchase_price_compute_type, move.product_id.last_purchase_price_compute_type,
) )
move.product_id.with_company( move.product_id.with_company(
@ -75,4 +80,5 @@ class StockMove(models.Model):
).last_purchase_price_received = price_updated ).last_purchase_price_received = price_updated
move.product_id.with_company( move.product_id.with_company(
move.company_id move.company_id
).product_tmpl_id._compute_theoritical_price() )._compute_theoritical_price()
return res

View file

@ -2,32 +2,22 @@
<odoo> <odoo>
<data> <data>
<!-- Template: Group Orders Page (Eskaera) --> <!-- Template: Group Orders Page (Eskaera) -->
<template id="eskaera_page" name="Eskaera Page"> <template id="eskaera_page" name="Eskaera Page">
<t t-call="website.layout"> <t t-call="website.layout">
<div <div id="wrap" class="eskaera-page oe_structure oe_empty" data-name="Eskaera Orders">
id="wrap" <div class="container">
class="eskaera-page oe_structure oe_empty" <div class="row">
data-name="Eskaera Orders" <div class="col-lg-12">
> <h1>Available Orders</h1>
<div class="container"> <p class="text-muted" role="status">Browse and select an order to view its products.</p>
<div class="row">
<div class="col-lg-12">
<h1>Available Orders</h1>
<p
class="text-muted"
role="status"
>Browse and select an order to view its products.</p>
</div> </div>
</div> </div>
<div class="row mt-4"> <div class="row mt-4">
<div class="col-lg-12"> <div class="col-lg-12">
<!-- Editable area: Above orders list --> <!-- Editable area: Above orders list -->
<div <div class="oe_structure oe_empty" data-name="Before Orders" />
class="oe_structure oe_empty"
data-name="Before Orders"
/>
<t t-if="active_orders"> <t t-if="active_orders">
<div <div
@ -315,18 +305,13 @@
</t> </t>
</div> </div>
</t> </t>
<t t-else=""> <t t-else="">
<div class="eskaera-empty-state"> <div class="eskaera-empty-state">
<div <div class="alert alert-info" role="status" aria-live="polite">
class="alert alert-info" <p>No group orders available this week.</p>
role="status" </div>
aria-live="polite" </div>
> </t>
<p
>No group orders available this week.</p>
</div>
</div>
</t>
<!-- Editable area: Below orders list --> <!-- Editable area: Below orders list -->
<div <div
@ -379,89 +364,58 @@
</t> </t>
</template> </template>
<!-- Small QWeb snippets used to render translated confirmation strings <!-- Small QWeb snippets used to render translated confirmation strings
Rendered via ir.ui.view._render_template() with lang in context Rendered via ir.ui.view._render_template() with lang in context
to ensure server-side translation regardless of call stack. --> to ensure server-side translation regardless of call stack. -->
<template id="confirm_message_snippet" name="Confirm Message Snippet"> <template id="confirm_message_snippet" name="Confirm Message Snippet">
<t t-esc="_('Thank you! Your order has been confirmed.')" /> <t t-esc="_('Thank you! Your order has been confirmed.')" />
</template> </template>
<template <template id="confirm_pickup_label_snippet" name="Confirm Pickup Label Snippet">
id="confirm_pickup_label_snippet" <t t-esc="_('Pickup Day')" />
name="Confirm Pickup Label Snippet" </template>
>
<t t-esc="_('Pickup Day')" />
</template>
<!-- Shared template: Order Header --> <!-- Shared template: Order Header -->
<template id="order_header" name="Order Header"> <template id="order_header" name="Order Header">
<div t-att-class="header_class or 'eskaera-order-header'"> <div t-att-class="header_class or 'eskaera-order-header'">
<div class="d-flex gap-5 align-items-center mb-4"> <div class="d-flex gap-5 align-items-center mb-4">
<t <t t-set="image_to_show" t-value="group_order.image or (group_order.group_ids[0].image_1920 if group_order.group_ids else False)" />
t-set="image_to_show" <t t-if="image_to_show">
t-value="group_order.image or (group_order.group_ids[0].image_1920 if group_order.group_ids else False)" <img t-att-src="image_data_uri(image_to_show)" alt="Order image" class="order-thumbnail-md" />
/> </t>
<t t-if="image_to_show"> <div class="flex-grow-1">
<img <h1 class="mb-2"><t t-esc="header_title or group_order.name" /></h1>
t-att-src="image_data_uri(image_to_show)" <t t-if="group_order.description">
alt="Order image" <p class="text-muted mb-0 order-desc-full"><t t-esc="group_order.description" /></p>
class="order-thumbnail-md" </t>
/> </div>
</t> </div>
<div class="flex-grow-1"> </div>
<h1 class="mb-2"><t </template>
t-esc="header_title or group_order.name"
/></h1> <!-- Template: Group Order Shop (Eskaera) -->
<t t-if="group_order.description"> <template id="eskaera_shop" name="Eskaera Shop">
<p class="text-muted mb-0 order-desc-full"><t <t t-call="website.layout">
t-esc="group_order.description" <div id="wrap" class="eskaera-shop-page oe_structure oe_empty" data-name="Eskaera Shop">
/></p> <div class="container">
</t> <!-- Order Header Info Panel -->
<div class="row mb-4">
<div class="col-lg-12">
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'eskaera-order-header'" />
</t>
<div class="eskaera-order-header">
<div class="order-info-grid">
<div class="info-item">
<span t-att-class="'info-label'">Consumer Groups</span>
<span class="info-value"><t t-esc="', '.join(group_order.group_ids.mapped('name'))" /></span>
</div> </div>
</div> <t t-if="group_order.cutoff_day">
</div> <div class="info-item">
</template> <span t-att-class="'info-label'">Cutoff Day</span>
<span class="info-value"><t t-esc="day_names[int(group_order.cutoff_day) % 7]" /> (<t t-esc="group_order.cutoff_date.strftime('%d/%m/%Y')" />)</span>
<!-- Template: Group Order Shop (Eskaera) --> </div>
<template id="eskaera_shop" name="Eskaera Shop"> </t>
<t t-call="website.layout">
<div
id="wrap"
class="eskaera-shop-page oe_structure oe_empty"
data-name="Eskaera Shop"
>
<div class="container">
<!-- Order Header Info Panel -->
<div class="row mb-4">
<div class="col-lg-12">
<t t-call="website_sale_aplicoop.order_header">
<t
t-set="header_class"
t-value="'eskaera-order-header'"
/>
</t>
<div class="eskaera-order-header">
<div class="order-info-grid">
<div class="info-item">
<span
t-att-class="'info-label'"
>Consumer Groups</span>
<span class="info-value"><t
t-esc="', '.join(group_order.group_ids.mapped('name'))"
/></span>
</div>
<t t-if="group_order.cutoff_day">
<div class="info-item">
<span
t-att-class="'info-label'"
>Cutoff Day</span>
<span class="info-value"><t
t-esc="day_names[int(group_order.cutoff_day) % 7]"
/> (<t
t-esc="group_order.cutoff_date.strftime('%d/%m/%Y')"
/>)</span>
</div>
</t>
<t t-if="group_order.pickup_day"> <t t-if="group_order.pickup_day">
<div class="info-item"> <div class="info-item">
<span <span
@ -516,38 +470,21 @@
<!-- Search and Filter Bar (Full Width, Above Products/Cart) --> <!-- Search and Filter Bar (Full Width, Above Products/Cart) -->
<div class="mb-3" id="realtimeSearch-filters"> <div class="mb-3" id="realtimeSearch-filters">
<div class="row g-2"> <div class="row g-2">
<div class="col-md-7"> <div class="col-md-7">
<!-- CRITICAL: This input is NOT inside a form to prevent Odoo from transforming it --> <!-- CRITICAL: This input is NOT inside a form to prevent Odoo from transforming it -->
<!-- It must remain a pure HTML input element for realtime_search.js to detect value changes --> <!-- It must remain a pure HTML input element for realtime_search.js to detect value changes -->
<input <input type="text" id="realtime-search-input" class="form-control realtime-search-box search-input-styled" placeholder="Search products..." autocomplete="off" />
type="text" </div>
id="realtime-search-input" <div class="col-md-5">
class="form-control realtime-search-box search-input-styled" <select name="category" id="realtime-category-select" class="form-select">
placeholder="Search products..." <option value="">Browse Product Categories</option>
autocomplete="off" <!-- Macro para renderizar categorías recursivamente -->
/> <t t-call="website_sale_aplicoop.category_hierarchy_options">
</div> <t t-set="categories" t-value="category_hierarchy" />
<div class="col-md-5"> <t t-set="depth" t-value="0" />
<select </t>
name="category" </select>
id="realtime-category-select" </div>
class="form-select"
>
<option
value=""
>Browse Product Categories</option>
<!-- Macro para renderizar categorías recursivamente -->
<t
t-call="website_sale_aplicoop.category_hierarchy_options"
>
<t
t-set="categories"
t-value="category_hierarchy"
/>
<t t-set="depth" t-value="0" />
</t>
</select>
</div>
</div> </div>
<t t-if="available_tags"> <t t-if="available_tags">
<div class="row mt-3"> <div class="row mt-3">
@ -606,11 +543,8 @@
<div class="row g-2"> <div class="row g-2">
<!-- Products Column --> <!-- Products Column -->
<div class="col-lg-9"> <div class="col-lg-9">
<!-- Editable area: Above search/filter --> <!-- Editable area: Above search/filter -->
<div <div class="oe_structure oe_empty" data-name="Before Products Filter" />
class="oe_structure oe_empty"
data-name="Before Products Filter"
/>
<t t-if="products"> <t t-if="products">
<div class="products-grid"> <div class="products-grid">
@ -826,15 +760,11 @@
</t> </t>
</div> </div>
</t> </t>
<t t-else=""> <t t-else="">
<div <div class="alert alert-warning" role="status" aria-live="polite">
class="alert alert-warning" <p>No products available in this order.</p>
role="status" </div>
aria-live="polite" </t>
>
<p>No products available in this order.</p>
</div>
</t>
<!-- Editable area: Below products list --> <!-- Editable area: Below products list -->
<div <div
class="oe_structure oe_empty mt-4" class="oe_structure oe_empty mt-4"
@ -851,26 +781,11 @@
<div <div
class="card-header d-flex justify-content-between align-items-center gap-1" class="card-header d-flex justify-content-between align-items-center gap-1"
> >
<h6 <h6 class="mb-0 cart-title-sm" id="cart-title">My Cart</h6>
class="mb-0 cart-title-sm" <div class="btn-group cart-btn-group gap-0" role="group">
id="cart-title" <button type="button" class="btn btn-primary cart-btn-compact" id="save-cart-btn" t-attf-data-order-id="{{ group_order.id }}" data-bs-title="Save Cart" data-bs-toggle="tooltip">
>My Cart</h6> <i class="fa fa-save cart-icon-size" />
<div </button>
class="btn-group cart-btn-group gap-0"
role="group"
>
<button
type="button"
class="btn btn-primary cart-btn-compact"
id="save-cart-btn"
t-attf-data-order-id="{{ group_order.id }}"
data-bs-title="Save Cart"
data-bs-toggle="tooltip"
>
<i
class="fa fa-save cart-icon-size"
/>
</button>
<button <button
type="button" type="button"
class="btn btn-info cart-btn-compact" class="btn btn-info cart-btn-compact"
@ -897,63 +812,31 @@
</a> </a>
</div> </div>
</div> </div>
<div <div class="card-body cart-body-lg" id="cart-items-container" t-attf-data-order-id="{{ group_order.id }}" aria-labelledby="cart-title" aria-live="polite" aria-relevant="additions removals">
class="card-body cart-body-lg" <p class="text-muted">This order's cart is empty</p>
id="cart-items-container" </div>
t-attf-data-order-id="{{ group_order.id }}" <div class="card-footer bg-white text-center">
aria-labelledby="cart-title" <a t-attf-href="/eskaera/{{ group_order.id }}/checkout" class="btn btn-success checkout-btn-lg" data-bs-title="Proceed to Checkout" data-bs-toggle="tooltip">
aria-live="polite" Proceed to Checkout
aria-relevant="additions removals" </a>
> </div>
<p class="text-muted">
This order's cart is empty
</p>
</div>
<div class="card-footer bg-white text-center">
<a
t-attf-href="/eskaera/{{ group_order.id }}/checkout"
class="btn btn-success checkout-btn-lg"
data-bs-title="Proceed to Checkout"
data-bs-toggle="tooltip"
>
Proceed to Checkout
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Scripts (in dependency order) --> <!-- Scripts (in dependency order) -->
<!-- Load i18n_manager first - fetches translations from server --> <!-- Load i18n_manager first - fetches translations from server -->
<script <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/i18n_manager.js" />
type="text/javascript" <!-- Keep legacy helpers for backwards compatibility -->
src="/website_sale_aplicoop/static/src/js/i18n_manager.js" <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/i18n_helpers.js" />
/> <!-- Main shop functionality (depends on i18nManager) -->
<!-- Keep legacy helpers for backwards compatibility --> <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/website_sale.js" />
<script <!-- UI enhancements -->
type="text/javascript" <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/checkout_labels.js" />
src="/website_sale_aplicoop/static/src/js/i18n_helpers.js" <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/home_delivery.js" />
/> <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/realtime_search.js" />
<!-- Main shop functionality (depends on i18nManager) -->
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/website_sale.js"
/>
<!-- UI enhancements -->
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/checkout_labels.js"
/>
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/home_delivery.js"
/>
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/realtime_search.js"
/>
<!-- Initialize tooltips using native title attribute --> <!-- Initialize tooltips using native title attribute -->
<script type="text/javascript"> <script type="text/javascript">
@ -993,77 +876,51 @@
</t> </t>
</template> </template>
<!-- Sub-template: Checkout Order Summary Table with Translations --> <!-- Sub-template: Checkout Order Summary Table with Translations -->
<template id="eskaera_checkout_summary" name="Checkout Order Summary"> <template id="eskaera_checkout_summary" name="Checkout Order Summary">
<div class="checkout-summary-container"> <div class="checkout-summary-container">
<table <table class="table table-hover checkout-summary-table" id="checkout-summary-table">
class="table table-hover checkout-summary-table" <thead class="table-dark">
id="checkout-summary-table" <tr>
> <th class="col-name">Product</th>
<thead class="table-dark"> <th class="col-qty text-center">Quantity</th>
<tr> <th class="col-price text-right">Price</th>
<th class="col-name">Product</th> <th class="col-subtotal text-right">Subtotal</th>
<th class="col-qty text-center">Quantity</th> </tr>
<th class="col-price text-right">Price</th> </thead>
<th class="col-subtotal text-right">Subtotal</th> <tbody id="checkout-summary-tbody">
</tr> <tr id="checkout-empty-row" class="empty-message">
</thead> <td colspan="4" class="text-center text-muted py-4">
<tbody id="checkout-summary-tbody"> <i class="fa fa-inbox fa-2x mb-2" />
<tr id="checkout-empty-row" class="empty-message"> <p>This order's cart is empty</p>
<td colspan="4" class="text-center text-muted py-4"> </td>
<i class="fa fa-inbox fa-2x mb-2" /> </tr>
<p>This order's cart is empty</p> </tbody>
</td> </table>
</tr> <div class="checkout-total-section">
</tbody> <div class="total-row">
</table> <span class="total-label">Total</span>:
<div class="checkout-total-section"> <span class="total-amount" id="checkout-total-amount">0.00</span>
<div class="total-row"> <span class="currency"></span>
<span class="total-label">Total</span>: </div>
<span </div>
class="total-amount" </div>
id="checkout-total-amount" </template>
>0.00</span>
<span class="currency"></span>
</div>
</div>
</div>
</template>
<!-- Template: Group Order Checkout (Eskaera) --> <!-- Template: Group Order Checkout (Eskaera) -->
<template id="eskaera_checkout" name="Eskaera Checkout"> <template id="eskaera_checkout" name="Eskaera Checkout">
<t t-call="website.layout"> <t t-call="website.layout">
<div <div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Checkout" t-attf-data-delivery-product-id="{{ delivery_product_id }}" t-attf-data-delivery-product-name="{{ delivery_product_name }}" t-attf-data-delivery-product-price="{{ delivery_product_price }}" t-attf-data-home-delivery-enabled="{{ 'true' if group_order.home_delivery else 'false' }}" t-attf-data-pickup-day="{{ group_order.pickup_day }}" t-attf-data-pickup-date="{{ group_order.pickup_date.strftime('%d/%m/%Y') if group_order.pickup_date else '' }}" t-attf-data-delivery-notice="{{ (group_order.delivery_notice or '').replace(chr(10), ' ').replace(chr(13), ' ') }}">
id="wrap" <div class="container mt-5">
class="eskaera-checkout-page oe_structure oe_empty" <div class="row">
data-name="Eskaera Checkout" <div class="col-lg-10 offset-lg-1">
t-attf-data-delivery-product-id="{{ delivery_product_id }}" <!-- Header Section -->
t-attf-data-delivery-product-name="{{ delivery_product_name }}" <div class="mb-4">
t-attf-data-delivery-product-price="{{ delivery_product_price }}" <t t-call="website_sale_aplicoop.order_header">
t-attf-data-home-delivery-enabled="{{ 'true' if group_order.home_delivery else 'false' }}" <t t-set="header_class" t-value="'checkout-header'" />
t-attf-data-pickup-day="{{ group_order.pickup_day }}" <t t-set="header_title">Confirm Order: <t t-esc="group_order.name" /></t>
t-attf-data-pickup-date="{{ group_order.pickup_date.strftime('%d/%m/%Y') if group_order.pickup_date else '' }}" </t>
t-attf-data-delivery-notice="{{ (group_order.delivery_notice or '').replace(chr(10), ' ').replace(chr(13), ' ') }}" </div>
>
<div class="container mt-5">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<!-- Header Section -->
<div class="mb-4">
<t
t-call="website_sale_aplicoop.order_header"
>
<t
t-set="header_class"
t-value="'checkout-header'"
/>
<t
t-set="header_title"
>Confirm Order: <t
t-esc="group_order.name"
/></t>
</t>
</div>
<!-- Order Info Card --> <!-- Order Info Card -->
<div <div
@ -1204,21 +1061,13 @@
data-name="After Summary" data-name="After Summary"
/> />
<!-- Home Delivery Checkbox --> <!-- Home Delivery Checkbox -->
<div class="card border-0 shadow-sm mb-4"> <div class="card border-0 shadow-sm mb-4">
<div class="card-body"> <div class="card-body">
<div class="form-check"> <div class="form-check">
<input <input type="checkbox" class="form-check-input" id="home-delivery-checkbox" name="home_delivery" />
type="checkbox" <label class="form-check-label fw-bold" for="home-delivery-checkbox">Home Delivery</label>
class="form-check-input" </div>
id="home-delivery-checkbox"
name="home_delivery"
/>
<label
class="form-check-label fw-bold"
for="home-delivery-checkbox"
>Home Delivery</label>
</div>
<div <div
id="delivery-info-alert" id="delivery-info-alert"
class="alert alert-info mt-3 d-none" class="alert alert-info mt-3 d-none"
@ -1294,43 +1143,17 @@
/> />
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div <div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
class="checkout-actions d-grid gap-3" <button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" data-confirmed-label="Order confirmed" data-pickup-label="Pickup Day" aria-label="Confirm and send order" data-bs-title="Confirm Order" data-bs-toggle="tooltip">
id="checkout-form-labels" <i class="fa fa-check-circle" aria-hidden="true" t-translation="off" />
> <span>Confirm Order</span>
<button </button>
class="btn btn-success btn-lg" <a t-attf-href="/eskaera/{{ group_order.id }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" data-bs-title="Back to Cart" data-bs-toggle="tooltip">
id="confirm-order-btn" <i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
t-attf-data-order-id="{{ group_order.id }}" <span>Back to Cart</span>
data-confirmed-label="Order confirmed" </a>
data-pickup-label="Pickup Day" </div>
aria-label="Confirm and send order"
data-bs-title="Confirm Order"
data-bs-toggle="tooltip"
>
<i
class="fa fa-check-circle"
aria-hidden="true"
t-translation="off"
/>
<span>Confirm Order</span>
</button>
<a
t-attf-href="/eskaera/{{ group_order.id }}"
class="btn btn-outline-secondary btn-lg"
aria-label="Back to cart page"
data-bs-title="Back to Cart"
data-bs-toggle="tooltip"
>
<i
class="fa fa-arrow-left"
aria-hidden="true"
t-translation="off"
/>
<span>Back to Cart</span>
</a>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -1348,35 +1171,17 @@
console.log('[LABELS] Initialized from server:', window.groupOrderShop.labels); console.log('[LABELS] Initialized from server:', window.groupOrderShop.labels);
})(); })();
</script> </script>
<!-- Scripts (in dependency order) --> <!-- Scripts (in dependency order) -->
<!-- Load i18n_manager first - fetches translations from server --> <!-- Load i18n_manager first - fetches translations from server -->
<script <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/i18n_manager.js" />
type="text/javascript" <!-- Keep legacy helpers for backwards compatibility -->
src="/website_sale_aplicoop/static/src/js/i18n_manager.js" <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/i18n_helpers.js" />
/> <!-- Main shop functionality (depends on i18nManager) -->
<!-- Keep legacy helpers for backwards compatibility --> <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/website_sale.js" />
<script <!-- UI enhancements -->
type="text/javascript" <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/checkout_labels.js" />
src="/website_sale_aplicoop/static/src/js/i18n_helpers.js" <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/home_delivery.js" />
/> <script type="text/javascript" src="/website_sale_aplicoop/static/src/js/checkout_summary.js" />
<!-- Main shop functionality (depends on i18nManager) -->
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/website_sale.js"
/>
<!-- UI enhancements -->
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/checkout_labels.js"
/>
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/home_delivery.js"
/>
<script
type="text/javascript"
src="/website_sale_aplicoop/static/src/js/checkout_summary.js"
/>
<script type="text/javascript"> <script type="text/javascript">
// Auto-load cart from localStorage when accessing checkout directly // Auto-load cart from localStorage when accessing checkout directly
(function() { (function() {
@ -1412,46 +1217,38 @@
</t> </t>
</template> </template>
<!-- Recursive macro to render category hierarchy for select dropdown --> <!-- Recursive macro to render category hierarchy for select dropdown -->
<template <template id="category_hierarchy_options" name="Category Hierarchy Options">
id="category_hierarchy_options" <!--
name="Category Hierarchy Options" Macro para renderizar recursivamente la jerarquía de categorías.
> Todas las categorías son seleccionables, indentadas por profundidad.
<!--
Macro para renderizar recursivamente la jerarquía de categorías.
Todas las categorías son seleccionables, indentadas por profundidad.
Parámetros: Parámetros:
- categories: lista de categorías a renderizar - categories: lista de categorías a renderizar
- depth: nivel de profundidad actual (para padding/indentación) - depth: nivel de profundidad actual (para padding/indentación)
--> -->
<t t-foreach="categories" t-as="cat"> <t t-foreach="categories" t-as="cat">
<!-- Calcular padding basado en profundidad: 20px por nivel --> <!-- Calcular padding basado en profundidad: 20px por nivel -->
<t t-set="padding_px" t-value="depth * 20" /> <t t-set="padding_px" t-value="depth * 20" />
<!-- Crear prefijo visual con flechas según profundidad --> <!-- Crear prefijo visual con flechas según profundidad -->
<t t-set="prefix"> <t t-set="prefix">
<t t-foreach="range(depth)" t-as="i"></t> <t t-foreach="range(depth)" t-as="i"></t>
</t> </t>
<!-- Renderizar como opción indentada y seleccionable --> <!-- Renderizar como opción indentada y seleccionable -->
<option <option t-att-value="str(cat['id'])" t-attf-style="padding-left: {{ padding_px }}px;">
t-att-value="str(cat['id'])" <t t-esc="prefix" /><t t-esc="cat['name']" />
t-attf-style="padding-left: {{ padding_px }}px;" </option>
>
<t t-esc="prefix" /><t t-esc="cat['name']" />
</option>
<!-- Renderizar hijos recursivamente si existen --> <!-- Renderizar hijos recursivamente si existen -->
<t t-if="cat['children']"> <t t-if="cat['children']">
<t <t t-call="website_sale_aplicoop.category_hierarchy_options">
t-call="website_sale_aplicoop.category_hierarchy_options" <t t-set="categories" t-value="cat['children']" />
> <t t-set="depth" t-value="depth + 1" />
<t t-set="categories" t-value="cat['children']" /> </t>
<t t-set="depth" t-value="depth + 1" /> </t>
</t> </t>
</t> </template>
</t>
</template>
</data> </data>
</odoo> </odoo>