diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6495dec..b1da0c3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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. ``` -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. ``` diff --git a/product_price_category_supplier/__manifest__.py b/product_price_category_supplier/__manifest__.py index 9734b59..08e9bfd 100644 --- a/product_price_category_supplier/__manifest__.py +++ b/product_price_category_supplier/__manifest__.py @@ -8,6 +8,7 @@ "summary": "Add default price category to suppliers and bulk update products", "author": "Odoo Community Association (OCA), Criptomart", "license": "AGPL-3", + "website": "https://git.criptomart.net/criptomart/addons-cm", "depends": ["product_price_category", "sales_team", "product_main_seller"], "data": [ "security/ir.model.access.csv", diff --git a/product_sale_price_from_pricelist/CHANGELOG.md b/product_sale_price_from_pricelist/CHANGELOG.md new file mode 100644 index 0000000..f6554fb --- /dev/null +++ b/product_sale_price_from_pricelist/CHANGELOG.md @@ -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 diff --git a/product_sale_price_from_pricelist/README.md b/product_sale_price_from_pricelist/README.md index 4fbebb0..6fd86ff 100644 --- a/product_sale_price_from_pricelist/README.md +++ b/product_sale_price_from_pricelist/README.md @@ -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 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 diff --git a/product_sale_price_from_pricelist/__manifest__.py b/product_sale_price_from_pricelist/__manifest__.py index dfedd94..3303455 100644 --- a/product_sale_price_from_pricelist/__manifest__.py +++ b/product_sale_price_from_pricelist/__manifest__.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { # noqa: B018 "name": "Product Sale Price from Pricelist", - "version": "18.0.1.0.0", + "version": "18.0.2.0.0", "category": "product", "summary": "Set sale price from pricelist based on last purchase price", "author": "Odoo Community Association (OCA), Criptomart", diff --git a/product_sale_price_from_pricelist/migrations/18.0.2.0.0/post-migration.py b/product_sale_price_from_pricelist/migrations/18.0.2.0.0/post-migration.py new file mode 100644 index 0000000..c11a1ea --- /dev/null +++ b/product_sale_price_from_pricelist/migrations/18.0.2.0.0/post-migration.py @@ -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") diff --git a/product_sale_price_from_pricelist/models/product_product.py b/product_sale_price_from_pricelist/models/product_product.py index 425a8a2..a797d42 100644 --- a/product_sale_price_from_pricelist/models/product_product.py +++ b/product_sale_price_from_pricelist/models/product_product.py @@ -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): _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( - related="product_tmpl_id.last_purchase_price_received", + related="last_purchase_price_received", string="Last Purchase Price", 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( self, price_type, uom=None, currency=None, company=None, date=False ): diff --git a/product_sale_price_from_pricelist/models/product_template.py b/product_sale_price_from_pricelist/models/product_template.py index 2397270..09ef270 100644 --- a/product_sale_price_from_pricelist/models/product_template.py +++ b/product_sale_price_from_pricelist/models/product_template.py @@ -2,35 +2,43 @@ # @author Santi Noreña () # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). - -import logging - -from odoo import _ +from odoo import api from odoo import fields from odoo import models -from odoo.exceptions import UserError - -_logger = logging.getLogger(__name__) 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" + # Computed fields that delegate to product.product variants 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, + compute="_compute_last_purchase_price_updated", + inverse="_inverse_last_purchase_price_updated", + search="_search_last_purchase_price_updated", + store=True, ) list_price_theoritical = fields.Float( - "Theoritical price", - company_dependent=True, + string="Theoritical price", + compute="_compute_list_price_theoritical", + inverse="_inverse_list_price_theoritical", + search="_search_list_price_theoritical", + store=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, + compute="_compute_last_purchase_price_received", + inverse="_inverse_last_purchase_price_received", + search="_search_last_purchase_price_received", + store=True, ) last_purchase_price_compute_type = fields.Selection( [ @@ -41,173 +49,96 @@ class ProductTemplate(models.Model): ("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, + compute="_compute_last_purchase_price_compute_type", + inverse="_inverse_last_purchase_price_compute_type", + search="_search_last_purchase_price_compute_type", + store=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 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." - ) + @api.depends("product_variant_ids.last_purchase_price_updated") + def _compute_last_purchase_price_updated(self): + for template in self: + template.last_purchase_price_updated = ( + template.product_variant_ids[:1].last_purchase_price_updated + if template.product_variant_ids + else False ) - def action_update_list_price(self): - updated_products = [] - skipped_products = [] - + def _inverse_last_purchase_price_updated(self): for template in self: - if template.last_purchase_price_compute_type != "manual_update": - # First compute the theoretical price - template._compute_theoritical_price() - - 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, + if template.product_variant_ids: + template.product_variant_ids.write( + { + "last_purchase_price_updated": template.last_purchase_price_updated + } ) - updated_products.append( - ( - template.name or template.default_code, - old_price, - template.list_price, - ) + + def _search_last_purchase_price_updated(self, operator, value): + return [("product_variant_ids.last_purchase_price_updated", operator, value)] + + @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 - self.invalidate_recordset( - ["list_price", "list_price_theoritical", "last_purchase_price_updated"] - ) + def _search_list_price_theoritical(self, operator, value): + return [("product_variant_ids.list_price_theoritical", operator, value)] - # Build notification message - message = "" - if updated_products: - message += _("✓ Products updated: %s") % len(updated_products) - if len(updated_products) <= 5: - message += "\n\n" - for name, old, new in updated_products: - message += _("• %s: %.2f → %.2f\n") % (name, old, new) + @api.depends("product_variant_ids.last_purchase_price_received") + def _compute_last_purchase_price_received(self): + for template in self: + template.last_purchase_price_received = ( + template.product_variant_ids[:1].last_purchase_price_received + if template.product_variant_ids + else 0.0 + ) - if skipped_products: - if message: - message += "\n\n" - message += _("⚠ Skipped (manual update): %s") % len(skipped_products) - if len(skipped_products) <= 5: - message += "\n" - for name in skipped_products: - message += _("• %s\n") % name + def _inverse_last_purchase_price_received(self): + for template in self: + if template.product_variant_ids: + template.product_variant_ids.write( + { + "last_purchase_price_received": template.last_purchase_price_received + } + ) - if not updated_products and not skipped_products: - message = _("No products to update.") + def _search_last_purchase_price_received(self, operator, value): + return [("product_variant_ids.last_purchase_price_received", operator, value)] - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": _("Price Update"), - "message": message, - "type": "success" if updated_products else "warning", - "sticky": False, - "next": {"type": "ir.actions.act_window_close"}, - }, - } + @api.depends("product_variant_ids.last_purchase_price_compute_type") + def _compute_last_purchase_price_compute_type(self): + for template in self: + template.last_purchase_price_compute_type = ( + template.product_variant_ids[:1].last_purchase_price_compute_type + if template.product_variant_ids + else "without_discounts" + ) - def price_compute( - self, price_type, uom=None, currency=None, company=False, date=False - ): - """Return dummy not falsy prices when computation is done from supplier - info for avoiding error on super method. We will later fill these with - correct values. - """ - if price_type == "last_purchase_price": - return dict.fromkeys(self.ids, 1.0) - return super().price_compute( - price_type, uom=uom, currency=currency, company=company, date=date - ) + def _inverse_last_purchase_price_compute_type(self): + for template in self: + if template.product_variant_ids: + template.product_variant_ids.write( + { + "last_purchase_price_compute_type": template.last_purchase_price_compute_type + } + ) + + def _search_last_purchase_price_compute_type(self, operator, value): + 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() diff --git a/product_sale_price_from_pricelist/models/stock_move.py b/product_sale_price_from_pricelist/models/stock_move.py index abbaaf5..26521be 100644 --- a/product_sale_price_from_pricelist/models/stock_move.py +++ b/product_sale_price_from_pricelist/models/stock_move.py @@ -5,10 +5,11 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). import logging -import math -from odoo import exceptions, models, fields, api, _ -from odoo.tools import float_is_zero, float_round, float_compare +from odoo import models +from odoo.tools import float_compare +from odoo.tools import float_is_zero +from odoo.tools import float_round _logger = logging.getLogger(__name__) @@ -17,7 +18,7 @@ class StockMove(models.Model): _inherit = "stock.move" 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"): if ( move.product_id.last_purchase_price_compute_type @@ -67,7 +68,11 @@ class StockMove(models.Model): move.product_id.last_purchase_price_received, price_updated, 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.with_company( @@ -75,4 +80,5 @@ class StockMove(models.Model): ).last_purchase_price_received = price_updated move.product_id.with_company( move.company_id - ).product_tmpl_id._compute_theoritical_price() + )._compute_theoritical_price() + return res diff --git a/website_sale_aplicoop/views/website_templates.xml b/website_sale_aplicoop/views/website_templates.xml index 1fa6dc7..2236a3b 100644 --- a/website_sale_aplicoop/views/website_templates.xml +++ b/website_sale_aplicoop/views/website_templates.xml @@ -2,32 +2,22 @@ - -